From c56c55a374b71a2e9a163578f8cdef9c89a785a7 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Thu, 13 Aug 2026 23:03:07 +0100 Subject: [PATCH 01/69] Ask "whose name is this?" once, and let new() name its own cell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects, one shape: a registry keyed by op NAME was answering a question it does not know the answer to. #1284 — a user-defined `fn get`/`fn put` called under a handler was lowered to the host cell intrinsic. Three sites decided independently whether a bare `get` meant the user's declaration or the operation: the checker resolves user-fn-first (E201/E202 report the user's signature), the declared-effect row withheld the intrinsic when `_fn_sigs` owned the name, and the handler expression installed `get`/`put` unconditionally. From check-green source that gave a silently wrong value, a module WASM validation rejects, or a spurious [E602] naming a State operation the program never contained. `vera.slots.bare_call_denotes_user_fn` now states the checker's rule once; the bare-call dispatch, the three bare-FnCall inference sites, and mono discovery consume it, each over its own name table. The gate is on the DISPATCH, not the registries: the registries record which cell a name reaches, which is true whatever the program's declarations are called. Withholding an entry answered both questions with one table — which is why the gate-only fix measured during PR #1283's review produced a differently-broken module, and why it also cost `State.put(5)` its cell in a program declaring `fn put` (`unknown func: $vera.put`), now lowered as the checker always meant. #1285 — `new(State)` read the name-keyed `_effect_ops["get"]`, so under a multi-`State` row it took whichever family registered first while `old(State)` was already family-keyed. The two sides of one `ensures` clause read different cells: `ensures(new(State) == …)` under `effects(, State>)` was check- and verify-green and died at load, and where both cells share a width it loaded and refuted a discharged postcondition at runtime. Codegen now carries a family→getter registry built from the per-family CellNames the registration site already computes, and `_translate_new_expr` keys on `_state_effect_family` exactly as `_translate_old_expr` does. Both fixes are proved RED first and by cross-component differential: every expected value is read off the CHECKER's resolution, never off what codegen emits, and each case records what it did before so none can go vacuous. All 256 pre-existing conformance and example programs emit byte-identical WAT (instrument mutation-validated: 39 files move when the predicate is stubbed). Two existing tests encoded the pre-fix codegen answer as their oracle and are corrected: `user_get_under_handler` expected the cell's clone where the checker names the function's, and the qualified-shadow test asserted the loud link failure its own docstring called the wrong semantics. Spec §7.4 now states the declarations-first rule and §7.3.3 that a form naming its type argument names its cell. Pinning both raises in `_translate_new_expr`/`_translate_old_expr` surfaced a checker gap they share — a contract may name a `State` the row never declares — filed as #1298 with a KNOWN_ISSUES row and a test holding today's E699. Closes #1284 Closes #1285 Co-Authored-By: Claude --- AGENTS.md | 8 +- CHANGELOG.md | 6 + CLAUDE.md | 8 +- FAQ.md | 4 +- KNOWN_ISSUES.md | 3 +- README.md | 2 +- ROADMAP.md | 2 +- SKILL.md | 2 +- TESTING.md | 36 +- docs/SKILL.md | 2 +- docs/index.html | 2 +- docs/index.md | 2 +- docs/llms-full.txt | 14 +- docs/llms.txt | 2 +- spec/07-effects.md | 4 + .../conformance/ch07_op_name_user_shadow.vera | 130 ++++++ tests/conformance/ch07_state_new_family.vera | 82 ++++ tests/conformance/manifest.json | 31 ++ tests/test_handler_op_ownership_1284.py | 398 ++++++++++++++++++ tests/test_mono_effect_op_naming_1207.py | 17 +- .../test_nat_narrowing_return_differential.py | 35 +- tests/test_new_state_family_1285.py | 318 ++++++++++++++ vera/README.md | 10 +- vera/checker/calls.py | 20 +- vera/checker/core.py | 31 ++ vera/codegen/functions.py | 31 +- vera/monomorphize.py | 56 ++- vera/slots.py | 65 ++- vera/wasm/calls.py | 89 ++-- vera/wasm/calls_handlers.py | 33 +- vera/wasm/context.py | 46 +- vera/wasm/inference.py | 28 +- vera/wasm/operators.py | 32 +- 33 files changed, 1398 insertions(+), 151 deletions(-) create mode 100644 tests/conformance/ch07_op_name_user_shadow.vera create mode 100644 tests/conformance/ch07_state_new_family.vera create mode 100644 tests/test_handler_op_ownership_1284.py create mode 100644 tests/test_new_state_family_1285.py diff --git a/AGENTS.md b/AGENTS.md index 25ba5e08..20640e17 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Read `SKILL.md` for the full language reference. It covers syntax, slot referenc ### Conformance programs as reference -The conformance suite in `tests/conformance/` contains 214 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-two negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) instead must *fail* `check` with the E-code in their `expected_error` field. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. +The conformance suite in `tests/conformance/` contains 216 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-two negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) instead must *fail* `check` with the E-code in their `expected_error` field. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. ### Workflow @@ -187,9 +187,9 @@ Each stage is a module with a single public API function (`parse_file`, `transfo pytest tests/ -v # Run all tests (see TESTING.md) pytest tests/test_conformance.py -v # Conformance suite only mypy vera/ # Type-check the compiler -python scripts/check_conformance.py # All 214 conformance programs hold (positives pass; negatives fail with their E-code) +python scripts/check_conformance.py # All 216 conformance programs hold (positives pass; negatives fail with their E-code) python scripts/check_examples.py # All 42 examples must pass -python scripts/check_corpus_canonical.py # All 262 corpus programs in canonical form +python scripts/check_corpus_canonical.py # All 264 corpus programs in canonical form ``` Test helpers follow a pattern: `_check_ok(source)` / `_check_err(source, match)` / `_verify_ok(source)` / `_verify_err(source, match)`. See existing tests for examples. @@ -198,7 +198,7 @@ When implementing a new language feature, write the conformance program *first* ### Invariants -- All 214 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) must *fail* `check` with their `expected_error` E-code +- All 216 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) must *fail* `check` with their `expected_error` E-code - All 42 examples in `examples/` must pass `vera check` and `vera verify` - `mypy vera/` must be clean - `pytest tests/ -v` must pass diff --git a/CHANGELOG.md b/CHANGELOG.md index 976ab080..28ab9305 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Fixed + +- **A user-defined `fn get` / `fn put` is no longer hijacked by an enclosing handler** ([#1284](https://github.com/aallan/vera/issues/1284)). Three sites answered "does this `get` mean the user's declaration or the effect operation?" independently. The checker answers user-fn-first — `_check_call_with_args` looks a bare name up as a function before it looks it up as an operation, so a declaration named `get` owns every bare `get(...)` in its scope, which an arity or argument-type error at such a call site proves by reporting the *user's* signature (E201/E202). Codegen answered twice more: the declared-effect row in `vera/codegen/functions.py` withheld the intrinsic when `_fn_sigs` already owned the name, and the handler expression in `vera/wasm/calls_handlers.py` installed `get`/`put` unconditionally. From `vera check`-green source that produced, depending on the nesting shape, a **silently wrong value** (`nat_to_int(get(3))` under `handle[State](@Int = 5)` returned the cell's 5 for the function's 4, and the argument was not even emitted), a **module WASM validation rejects** (a `@Bool`-returning user `get` took `state_get_Int`'s i64 into an `i32` position; different-family nesting took the *enclosing* cell's getter at the wrong width), or a **spurious `[E602]`** in which the #1233 unaddressable-cell gate refused `main` outright, naming "a bare or qualified State operation `get`" the program never contained. The repair is one predicate, `vera.slots.bare_call_denotes_user_fn`, stating the checker's rule once and consumed by the bare-call dispatch in `vera/wasm/calls.py` (which now gates the clause-inline registry, the host-cell intrinsics and the addressability gate together), by the three bare-`FnCall` result-type inference sites, and by the monomorphizer's discovery walk — each passing its own name table, so the sites cannot answer differently about the table they share. Gating the *dispatch* rather than the *registries* is what makes it correct rather than merely consistent: the registries record which cell an op name reaches, which is true whatever the program's declarations are called, and withholding an entry answered both questions with one table. That is why the gate-only fix measured during PR #1283's review turned the loud skip into a differently-broken module, and why it also cost the qualified spelling its cell — `State.put(5)` in a function that also declares `fn put` compiled to `call $vera.put` and failed to link, which now lowers to the intrinsic the checker always meant. Discovery's `MonoContext.fn_names` moves to the same lookup-time question, so a `get(())` fixing a generic's type variable under a handler names the clone the rewrite emits. All 256 conformance and example programs emit byte-identical WAT. + +- **`new(State)` reads the cell its contract names** ([#1285](https://github.com/aallan/vera/issues/1285)). `old(State)` has been keyed on the resolved cell family since [#1205](https://github.com/aallan/vera/issues/1205)/[#1209](https://github.com/aallan/vera/issues/1209); `new(State)` read the name-keyed `_effect_ops["get"]`, which holds whichever `State` the effect row registered first. Under a single-`State` row the two keyings coincide, which is why the corpus agreed; under a multi-`State` row the two sides of one `ensures` clause read different cells. `effects(, State>)` with `ensures(new(State) == false)` was check-green *and* verify-green, emitted `state_get_Int`'s i64 into the Bool comparison's `i32.eq`, and died at load with wasmtime's raw `type mismatch: expected i32, found i64`. The type mismatch is the symptom rather than the defect: where both cells share a machine width — `State` beside `State` — the module loaded and answered about the other cell, so `ensures(new(State) == old(State))` on a function that writes neither was refuted at runtime on a contract the verifier had discharged. Codegen now carries a family→getter registry populated at the declared-row registration site from the per-family `CellNames` it already computes, and `_translate_new_expr` keys on `_state_effect_family` exactly as `_translate_old_expr` does. A bare `get(())` names no family and so is right to keep reading the name-keyed registry, source-order-first-wins; a contract names one and must not. + ## [0.1.11] - 2026-08-13 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index de912bb9..69d5935e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,9 +58,9 @@ VERA_JS_COVERAGE=1 pytest tests/test_browser.py -v # Browser tests with JS cove VERA_EAGER_GC=1 vera run file.vera # Force GC on every alloc (see ENVIRONMENT.md, debug knob for #593-class GC-rooting bugs) mypy vera/ # Type-check the compiler itself -python scripts/check_conformance.py # Verify all 214 conformance programs (positives pass their level; negatives fail with their expected_error E-code) +python scripts/check_conformance.py # Verify all 216 conformance programs (positives pass their level; negatives fail with their expected_error E-code) python scripts/check_examples.py # Verify all 42 examples parse + check + verify -python scripts/check_corpus_canonical.py # Verify all 262 corpus programs are in canonical form (vera fmt) +python scripts/check_corpus_canonical.py # Verify all 264 corpus programs are in canonical form (vera fmt) python scripts/check_examples_readme.py # Verify vera run commands in examples/README.md python scripts/check_spec_examples.py # Verify spec code blocks parse python scripts/check_readme_examples.py # Verify README code blocks parse @@ -92,7 +92,7 @@ See [`TOOLCHAIN.md`](TOOLCHAIN.md) for the CLI cookbook — driving the toolchai - `vera/` — Reference compiler: grammar, parser, AST, transformer, type checker, verifier, codegen, CLI - `examples/` — 42 example Vera programs (all must pass `vera check` and `vera verify`) - `tests/` — Test suite (unit tests + conformance suite) -- `tests/conformance/` — 214 conformance programs validating every language feature against the spec +- `tests/conformance/` — 216 conformance programs validating every language feature against the spec - `scripts/` — CI and validation scripts ## Writing Vera code @@ -129,7 +129,7 @@ Before changing code — **adding or removing** — write the test that proves y ## What not to break - Pre-commit hooks run mypy + pytest + conformance suite + example validation on every commit -- All 214 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) must *fail* `check` with their `expected_error` E-code +- All 216 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) must *fail* `check` with their `expected_error` E-code - All 42 examples in `examples/` must pass `vera check` and `vera verify` - Version must stay in sync across `pyproject.toml`, `vera/__init__.py`, `docs/index.html`, `README.md`, and `uv.lock` (gated by `scripts/check_version_sync.py`); CHANGELOG.md must also carry a matching `## [X.Y.Z]` section - All tests must pass: `pytest tests/ -v` diff --git a/FAQ.md b/FAQ.md index 3d6256c2..1fadedae 100644 --- a/FAQ.md +++ b/FAQ.md @@ -236,7 +236,7 @@ None of this is Vera-specific, but it validates the design choices. The thesis i This is a real concern. LLMs are trained on trillions of tokens of Python, TypeScript, and JavaScript. A MojoBench study (NAACL 2025) found that even fine-tuned models achieved only 30–35% improvement over base models on Mojo code generation, illustrating the cold-start problem for new languages. -Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (214 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification. +Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (216 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification. ## How does Vera compare to Dafny / Lean / Koka / F*? @@ -279,7 +279,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 10,486 tests, including a 214-program conformance suite +- 10,532 tests, including a 216-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index e8c90510..c6b286b9 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -8,6 +8,7 @@ Defects in shipped compiler, runtime, or tooling behaviour — this table matche | Bug | Issue | |-----|-------| +| A postcondition may name a `State` the function's effect row never declares, and `vera check` accepts it: `ensures(new(State) == false)` under `effects(>)` reports OK, then `vera compile` fails with **E699** — the internal-compiler-error diagnostic whose own text says the type checker should have rejected the input, which is exactly the situation. Both forms land there for the same reason (no cell of that family exists, so `old()` finds no snapshot local and, since [#1285](https://github.com/aallan/vera/issues/1285), `new()` finds no getter). Loud and never a wrong answer, so this is diagnostic quality rather than soundness — but it is a check-green program that cannot compile, reported against the compiler instead of against the program, with a bug-report request the user should not act on. Before #1285 the `new()` side was worse than loud: the name-keyed lookup found the row's other getter and silently read the wrong cell. Fix direction: validate an `OldExpr`/`NewExpr`'s effect reference against the declared row where the checker already validates the rest of the clause, one rule for both forms; `test_a_family_the_row_does_not_declare_is_loud_on_both_sides` pins today's E699 and is the test to flip. | [#1298](https://github.com/aallan/vera/issues/1298) | | Spec §1.4 says its reserved keywords MUST NOT be used as function names, and for **seventeen of them nothing enforces it**: `then`, `else`, `data`, `type`, `module`, `import`, `public`, `private`, `requires`, `ensures`, `invariant`, `decreases`, `effect`, `with`, `in`, `where` and `pure` all declare and pass `vera check` as `public fn (@Int -> @Int)`. (`handle` is an eighteenth acceptance and is the sanctioned host-entry-point carve-out, §5.2; the other eleven names — `resume`, `exists`, `forall`, `match`, `assert`, `assume`, `fn`, `let`, `if`, `true`, `false` — are refused **E153**.) They are not even traps: `private fn with(@Int -> @Int)` and its `where` / `type` / `pure` / `requires` / `import` siblings are **callable** from the same file, so the declaration resolves and runs like any other function. The comment above `_KEYWORD_FN_NAMES` in `vera/checker/registration.py` gives the opposite as the reason those names are absent from the set — that the contextual lexer "does not admit them as a function name, so no declaration reaches this checker at all" — so the omission rests on a premise the tree refutes, and neither the parser nor the checker holds the MUST. Two resolutions, and the choice is a ruling rather than a mechanical fix: extend `_KEYWORD_FN_NAMES` so the seventeen join `resume` under E153, or narrow §1.4's MUST to the names actually reserved. The `resume` precedent leans to enforcing — `resume` is likewise not a keyword token anywhere and likewise parses, and was reserved *at the checker* precisely because the parser had no keyword to refuse it with — and E153's own rationale, that a keyword names one construct and nothing else (DESIGN principle 3), does not depend on the name being a trap. Until the ruling lands, §1.4's `handle` carve-out sentence deliberately makes no enforcement claim, so the spec does not assert a MUST it cannot back. | [#1296](https://github.com/aallan/vera/issues/1296) | | The browser runtime's `md_render` breaks the round-trip property spec §9.7.6 states for it — `md_parse(md_render(b)) == Ok(b)` does not hold for its output — and with it §12.9.3's identical-results requirement, which names Markdown explicitly. The scope is **any multi-line paragraph**, not the list lazy continuation first observed: the browser preserves a paragraph's internal soft line breaks and does not re-apply the container prefix (`> `, list-item indent) on output, where the native renderer collapses them to spaces. The browser render is also **not stable** — re-rendering its own output moves content out of its container, so `> a b` renders as two lines and then re-renders with the second line no longer quoted — and on a blockquote wrapping a heading and a fenced block it destroys the document: the fence fragments into three and its contents escape the quote, past recovery by any subsequent parse. The native renderer is a fixed point on every case. Parsing is unaffected — `md_has_heading`, `md_has_code_block` and `md_extract_code_blocks` are byte-identical across runtimes on the same inputs — so the defect is scoped precisely to the renderer in `vera/browser/runtime.mjs`. `tests/test_browser.py` pins both runtimes' exact current strings, including the destructive blockquote case, so a fix on either side goes red until the pins are updated deliberately. | [#1294](https://github.com/aallan/vera/issues/1294) | | `json_stringify` output differs between the native and browser runtimes on two independent axes: **separators** — `json.dumps` defaults to `", "` / `": "` where `JSON.stringify` emits none — and **integral numbers**, since native `read_json` produces a Python `float`, so a number parsed from `1` re-renders as `1.0` where the browser renders `1`. Neither host is individually wrong (spec §9.7.5 pins no output format and names both libraries), but §12.9.3 requires all non-IO operations to produce identical results in both runtimes, so the divergence itself is the defect. Key ordering is insertion order on both sides and both are idempotent under re-stringify. A third asymmetry folds in here: stringifying a NaN number **traps** natively (`allow_nan=False` in `vera/runtime/json.py`) while the browser silently emits `null` — crash versus silent wrong value. Fix direction, per DESIGN principle 3 (one canonical form): pick one serialization and state it in §9.7.5; the compact `JSON.stringify` form is the more conventional and avoids the `1` to `1.0` document mutation, which makes the native side the better candidate to change. `tests/test_browser.py` pins both runtimes' exact strings — five assertions there go red when the hosts converge, and eleven Node-only tag tests become full parity tests. | [#1293](https://github.com/aallan/vera/issues/1293) | @@ -15,8 +16,6 @@ Defects in shipped compiler, runtime, or tooling behaviour — this table matche | `.github/workflows/release.yml`'s `Tag and create GitHub Release` step fails with `HTTP 422: Validation Failed — body is too long (maximum is 125000 characters)` when the CHANGELOG section `scripts/release.py notes` extracts into `RELEASE_NOTES.md` exceeds GitHub's release-body limit. It fired on v0.1.10, whose notes extract to roughly 148,700 bytes — the #1213 burndown's 44-issue section, some 23,000 characters past the limit — and it fired at the worst point in the pipeline: **after** PyPI had accepted the immutable archives and **after** the tag was created, leaving the release half-cut with no repeatable path back. The v0.1.10 GitHub Release was completed by hand, mirroring the step exactly: the run's artifacts downloaded and hash-verified three ways, then `gh release create --verify-tag --latest` with the wheel, sdist and SHA256SUMS, and a generated body — the section's bold bullet lead-ins as a headline index plus a link to the canonical section at the tag. Rare, since it needs a release this large, but rarity is not the mitigating factor here; the landing point is. Fix direction: make the step total — before `gh release create`, regenerate oversized notes into that index form so the release always carries a body that fits, with the full notes staying in `CHANGELOG.md`, which is already the release notes of record. | [#1288](https://github.com/aallan/vera/issues/1288) | | `_stamp_decl_order` skips the prelude stamp when a main-file `type` reuses a prelude name: `type Option = Int` checks green and leaves the prelude's `Option` without `_prelude_decl_order`, so inside a module namespace it resolves at `_BUILTIN_DECL_INDEX` instead of its prelude position. Latent — the shadow wins the arbitration by namespace rules before the index is consulted, and no observably-wrong check-green program has been constructed — but the stamp is wrong and the next decl-order consumer inherits it. Fix direction: stamp the prelude declaration unconditionally; its order is a fact about the prelude, not about what the main file declares. | [#1287](https://github.com/aallan/vera/issues/1287) | | `_infer_vera_type` (monomorphization's Vera-level type namer) still reads only `then_branch` of an `IfExpr` and only `arms[0]` of a `MatchExpr` — the #1276 join fix landed in its WAT-side siblings but not here. Confirmed by inspection; latent: a wrong answer needs an instantiation discovered through a divergent-first-arm conditional, and no check-green program reaching that shape has been constructed (the checker's upstream unification constrains the same joins). The fix is the mechanical #1276 mirror — take the first arm that yields a type — landing with the failing program that proves it, per the test-first rule. | [#1286](https://github.com/aallan/vera/issues/1286) | -| `new(State)` under a multi-`State` effect row reads the name-keyed `_effect_ops["get"]` — whichever family's getter was installed last — where `old(State)` is family-keyed: `effects(, State>)` with `ensures(new(State) == true)` is check- and verify-green, emits `$vera.state_get_Int`'s i64 into the Bool comparison's `i32.eq`, and dies at load with wasmtime's raw type mismatch. Fix direction: thread a family→getter registry through codegen so `new()` keys the lookup the way `old()` already does. | [#1285](https://github.com/aallan/vera/issues/1285) | -| A user-defined `fn get`/`fn put` called in a handler clause body is hijacked by the lowering: the checker resolves user-fn-first (E202 reports against the user signature) and `codegen/functions.py` guards its intrinsic mapping on `_fn_sigs`, but the clause-scope installation in `calls_handlers.py` overwrites `get`/`put` unconditionally — same-family nesting skips the function with a spurious [E602], different-family nesting emits `call $vera.state_get_` for the user call and the module fails WASM validation, all from check-green source. The gate-only fix was measured to convert the loud skip into the broken module, so the repair is the #1213-shaped one: a single call-site ownership predicate consumed by the checker and both codegen sites. | [#1284](https://github.com/aallan/vera/issues/1284) | | E608 rejects two modules' same-named generics that the #1274 ownership classification makes provably distinct: a diamond where `base` declares a public `forall fn gen` and `mid1` a private one is refused with `Function 'gen' is defined in both imported module 'mid1' and 'base'`, though after #1274 the two occupy different clone namespaces (`gen$Bool` vs `mod$mid1$gen$Bool`) and nothing is emitted under a generic's bare name at all. Loud (a refused compile), never a wrong answer, and pre-existing. Not relaxed alongside #1274 because the rail is keyed on `_fn_sigs`, where a generic DOES take a bare entry that flows into `MonoContext.fn_names` — the table the #1207 effect-op shadow guard consults — so letting two modules' generic signatures share that key would silently pick a winner for a consumer the clone classification says nothing about. Fix direction: give the collision rail the same ownership predicate, and in the same change either keep generics out of the shared bare `_fn_sigs` injection or key their entries per owner. | [#1281](https://github.com/aallan/vera/issues/1281) | | Codegen's per-namespace ADT membership (#1253) recovers the global infrastructure by subtracting what the namespaces DECLARE from the registered layouts, and that subtraction is not namespace-scoped: when a module declares `data Json`, `Json` leaves the infrastructure set and is therefore absent from the member set of every namespace except that module's — including the entry program's, which never declared it and legitimately sees the prelude's. The Pass-0.5 built-in snapshot unioned in as a floor predates the Pass-1.2 prelude injection and so does not protect these names (the same asymmetry #1253 fixed, one layer down), and no E609/E610 rail fires on the declaration because those rails key on that same snapshot. **Inert at emission today** — `AliasEnv.data_types` changes an answer only in `naming._resolve_named`, and only for `Decimal` and the single `REMOVED_ALIASES` entry `Float`, so the whole corpus is byte-identical with and without the membership rule; this is a latent constraint on the next consumer rather than a live defect, and it is not release-gating. Fix direction: scope the subtraction per namespace, or teach Pass 0.5 which prelude ADTs the program will demand — **not** by reserving the names, which spec §8.4.1 forbids (the prelude's data types are ordinary public declarations a program may name and shadow; the reserved namespace is the `Vera` prefix alone, E154). Recorded alongside it: a program where a module declares `data Json` AND the entry uses prelude `Json` checks clean and then compiles to nothing (E602 inside the prelude's own `json_get`, an E620 cascade, no exports) — a diagnostic-quality problem, not soundness, since it refuses to emit rather than emitting something wrong, but the error points into `` instead of at the user's `data Json`. Within-namespace shadowing is unaffected. | [#1277](https://github.com/aallan/vera/issues/1277) | | A `throw` payload is statically obligated but not runtime-guarded: `throw(v)` narrows `v` into the `Exn` payload and the narrowing now carries the same obligation every other binding site does — a provably-violating value is a loud E503/E505 and an undischargeable one is a disclosed `tier3_unguarded` E504/E506/E531 — but codegen emits no predicate or sign guard on the payload (`throw` lowers straight to `throw $exn_` with the value on the stack), so an unverified `vera compile`/`run` still passes a violating value through. Same disclosed-unguarded class as the user-effect operation argument in #754 below, and the guard wants the same metadata; fix direction: emit the guard at the `throw` op-call site, reusing the #1203 `put`-store guard emission and the §2.6.5 boundary predicate lowering. | [#1268](https://github.com/aallan/vera/issues/1268) | diff --git a/README.md b/README.md index 990b438b..a96b2a81 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ cp /path/to/vera/SKILL.md ~/.claude/skills/vera-language/SKILL.md ## Project status -Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 10,486 tests, 95% Python code coverage, 214 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. +Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 10,532 tests, 95% Python code coverage, 216 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. The reference compiler — parser, AST, type checker, contract verifier (Z3), WASM code generator, module system, browser runtime, and runtime contract insertion — is working. The language specification is in draft across [14 chapters](spec/). diff --git a/ROADMAP.md b/ROADMAP.md index d3fd45c8..6bcfd5c2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,7 +12,7 @@ Ordering derives from the design principles ([DESIGN.md](DESIGN.md)): verificati ## Where we are -10,486 tests, 214 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. +10,532 tests, 216 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. ## Stage 19 — The verification completeness sprint diff --git a/SKILL.md b/SKILL.md index fedd61b3..3e1ba061 100644 --- a/SKILL.md +++ b/SKILL.md @@ -2452,7 +2452,7 @@ public fn main(@Unit -> @Unit) ## Conformance Suite -The `tests/conformance/` directory contains 214 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. +The `tests/conformance/` directory contains 216 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. Each program is organized by spec chapter (`ch01_int_literals.vera`, `ch04_match_basic.vera`, `ch07_state_handler.vera`, etc.) and the `manifest.json` file maps features to programs. When you need to see how a specific construct works, check the conformance program before reading the spec. diff --git a/TESTING.md b/TESTING.md index b217061f..730aa375 100644 --- a/TESTING.md +++ b/TESTING.md @@ -6,9 +6,9 @@ This is the single source of truth for Vera's testing infrastructure, coverage d | Metric | Value | |--------|-------| -| **Tests** | 10,486 across 162 files (~152,000 lines of test code; 10,320 passed + 26 stress, 140 skipped) | +| **Tests** | 10,532 across 164 files (~152,000 lines of test code; 10,364 passed + 26 stress, 142 skipped) | | **Compiler code coverage** | 95% Python, 87% JavaScript (CI minimum: 80%) | -| **Conformance programs** | 214 programs across 9 spec chapters, validating every language feature | +| **Conformance programs** | 216 programs across 9 spec chapters, validating every language feature | | **Example programs** | 42, all validated through `vera check` + `vera verify` | | **Spec code blocks** | 189 parseable blocks from 14 spec chapters: 92 parse, 86 type-check, 85 verify (the rest carry inline `vera:skip` annotations, #538) | | **README code blocks** | 4 Vera blocks (4 validated, 0 annotated) | @@ -39,7 +39,7 @@ VERA_EAGER_GC=1 pytest tests/test_codegen_closures.py::TestClosureReturnShadowPu mypy vera/ # strict mode # Validation scripts -python scripts/check_conformance.py # conformance suite (214 programs, see manifest.json) +python scripts/check_conformance.py # conformance suite (216 programs, see manifest.json) python scripts/check_examples.py # 42 example programs python scripts/check_spec_examples.py # spec code blocks python scripts/check_readme_examples.py # README code blocks @@ -76,6 +76,8 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_byte_literal_joins_1212.py` | 25 | 759 | #1212 — a `@Byte` literal inside a value-position join lowers at the i32 Byte width. `@Byte` is i32 (spec §11) while an int literal defaults to `i64.const`, and the #865 / #1092 coercions each tested for a TOP-LEVEL `IntLit` — which the checker's bidirectional coercion is equally happy to type inside an `if` or `match` branch. Ten write boundaries are parametrized with the literal in a branch (`let`, handler state-init, clause-dispatched `put`, bare `put`, get-clause `resume` — verbatim the form the E602 clause-lowerability skip message recommends — a clause `with` update, a `@Byte` call argument, a generic constructor field at `Box`, a lifted closure's own RETURN whose named twin had been coerced since #865 while the closure path had no such step, and a HETEROGENEOUS join at a `@Byte` return, where the arm the result-type decider reads is already i32 and a sibling is a bare literal — arm ORDER decided which way that one failed, so both orders and both paths are pinned). The module docstring states what that list is and is not: measured coverage, since the checker's single Byte coercion makes the true enumeration "every position propagating a Byte expectation", which nothing enumerates in one place, each a check-green program that failed WASM validation with `type mismatch: expected i32, found i64` before the fix. Every case carries a VALUE oracle (200, distinguishable from every other constant in its fixture) rather than merely asserting the module runs, and a separate test drives the OTHER branch so a fix that marked only the arm the result type is read off would still fail. The controls are the load-bearing half: a plain `@Int` join must stay i64 — pinned on 5,000,000,000, which an i32 store cannot represent, so a spreading mark is a wrong VALUE and not just a validation failure — a Byte join with no literal arm must be untouched, and a Byte-RETURNING literal join must keep its own #865 return coercion. The constructor-field case runs through the real pipeline (checker artifacts threaded), because the #1092 width keys on the checker-recorded target type | | `test_closure_boundary_widths_1255_1256_1269.py` | 52 | 949 | Widths and pointer-ness at a closure or effect boundary — three burndown defects of one seam, each a boundary answering "what is this declared type" from something other than that type. **#1255**: GC pointer-ness was read off the SYNTACTIC head, so `type SmallByte = { @Byte \| ... }` was rooted on the shadow stack at the closure parameter, return and capture and at the two named-function twins. The oracle is a DIFFERENTIAL against the `@Byte` spelling of the same program rather than an absolute push count — these bodies must allocate or no prologue is emitted at all, so they legitimately root their own intermediates — with the base spelling's own count PINNED beside it, because equality alone also holds when both spellings root the scalar, which is the pre-fix state and what a mutation deleting the exclusion outright would produce. A genuine pointer at each of the four boundaries is the control (rooting nothing anywhere satisfies the differential too), and every shape runs under `VERA_EAGER_GC=1` — a collection at each `$alloc`, where removing a load-bearing push reads back as a wrong value rather than as a passing test. The heap-layout invariant the defect was inert behind is executable here: a module with no string pool at all — the exposure the issue named — still starts its heap above the inline scalar range, and shrinking the two constants that create that margin fires the build guard. **#1256**: the `apply_fn` `call_indirect` signature took each parameter's width from the ARGUMENT, so a `@Byte` formal fed a literal registered two incompatible `$closure_sig` types and trapped; asserted by run AND by the emitted signature list, since a value oracle alone would also pass if both sides converged on the wrong shared width. The join spelling, the refined formal, the function-type-alias arm of the formal recovery, a directly-called named twin and an i64 control (pinned above 2^32, which an i32 parameter cannot carry) surround it. **#1269**: `throw`'s payload was not a `@Byte` write boundary, so `throw(5)` into `Exn<{ @Byte \| @Byte.0 < 10 }>` put an `i64.const` under an i32 tag and failed WASM validation at load. Both halves of the width agreement are pinned — a fix that widened the TAG would also run, and would put a Byte cell at eight bytes everywhere else — across the bare, aliased, refined, branch-literal, qualified-`Exn.throw` and thrown-inside-the-handled-body spellings, the last two reaching registration paths the others do not | | `test_nested_handler_clause_ops.py` | 27 | 971 | #1211 — a handler clause body's bare `get`/`put` belongs to the handler's DECLARATION scope, not to the body it refines. Eight nested shapes, each asserted on all three components (checker accepts, verifier discharges clean, compiled program returns the checker-derived value): `put` in a put clause and in a get clause, a bare `get` in a `with` state-update expression, depth-3 nesting proving the IMMEDIATELY enclosing handler wins, the qualified `State.put` spelling, a nested handle expression inside a clause body (its registries must be restored to the declaration's, not the intervening handler's), and the two op-result-type mirrors — a bare `get(())` in match-scrutinee (`_effect_op_result_wt`) and array-element (`_effect_op_result_vera`) position, both of which emitted invalid WASM for a check-green program before the alignment. Every oracle is derived from the checker's story, never from what codegen emits, and a meta-test asserts each shape still SEPARATES enclosing-cell from inner-cell routing (the pre-fix value is recorded per case) so none can go vacuous. Round two adds the two dispositions of an EMPTY enclosing handler stack — the declared effect row (the only route that reads the restored `_effect_ops`, which every handler-enclosed case bypasses) and the outermost handler in a `pure` function (E122 at check) — the enclosing handler's own clause running on the outward-routed op (a transforming `with` one level out: 300100, where the intrinsic reading gives 300050), `IO.print` inside a clause body, the #1233 same-family refusals (nested handler, `with` expression, declared row) with their different-family control, and the outward-re-entry depth cap (below it, at it with a WAT-size bound, and past it as a loud E602) | +| `test_handler_op_ownership_1284.py` | 15 | 398 | #1284 — whose declaration a bare `get`/`put` call site denotes. The checker resolves user-fn-first (pinned directly: an over-applied `get` under a `handle[State]` reports the USER signature's arity), and codegen used to answer that question twice more and differently — the declared-effect row withheld the op when a function owned the name, the handler expression overwrote unconditionally. Four shapes from check-green source, each asserted on the CHECKER's value and on the dispatch target in the emitted WAT: a handled body returning the cell instead of the function's answer (silently 5 for 4), a `@Bool`-returning user `get` whose module WASM validation rejected, same-family nesting refused outright with a spurious `[E602]` naming a State operation the source never contained, and different-family nesting emitting the enclosing cell's getter at the wrong width. A parametrized differential runs all five shapes (the four plus a user `put`) as one table with each case's pre-fix behaviour recorded, so a case that stops distinguishing the two answers is visible rather than vacuous; the controls — an unshadowed handler and an unshadowed declared row, both of which must still reach the intrinsics — are what a fix that simply stopped installing the ops would fail, and `new(State)` under a shadowed op name pins that the #1285 family registry composes with this | +| `test_new_state_family_1285.py` | 9 | 318 | #1285 — which cell `new(State)` reads under a multi-`State` effect row. `old()` has been family-keyed since #1205/#1209 while `new()` read the name-keyed op registry, so the two sides of one `ensures` clause read different cells: `effects(, State>)` with `ensures(new(State) == …)` was check-green and verify-green, put `state_get_Int`'s i64 into the Bool comparison's `i32.eq`, and died at load. Three multi-row cases — the width-mismatched shape that could not load, an `Int`/`Nat` pair that loaded and answered about the wrong cell, and `old()` beside `new()` of one family, whose unchanged-cell claim the runtime refuted on a contract the verifier had discharged — plus the single-`State` and alias-spelled controls the whole existing corpus exercises. Each cell is seeded from a caller's handler at a value the other cell is not holding, so a wrong-cell read cannot coincide with the right answer, and a deliberately false postcondition asserts the Tier 3 runtime check really traps, without which every "the program runs" assertion here would prove nothing | | `test_adt_membership_scope_1253.py` | 5 | 334 | #1253 — a checker↔codegen DIFFERENTIAL over one module's slot table. `_adt_layouts` is one map across every absorbed namespace, so a sibling module's ADTs were members of a module that never imported them while the checker kept the name opaque: `['Array', 'Array']` against `['Array', 'Array']` for the same signature. Each case renders the module's parameters through `vera.naming` twice — once against the environment the checker binds that module's declarations in (built by the production `_modules_visible_to` + `check_program` path, not a rebuild of it) and once against codegen's `_alias_env` inside `_module_alias_scope` — and asserts both the agreement and the checker's own value, so an alignment on the WRONG name still fails. Three membership cases (an unimported public sibling, a private sibling, and the imported positive control that is green before and after — what separates scoping the membership from erasing cross-module ADTs) plus the entry program's own view, which must keep seeing the ADT it imports by name | | `test_import_visibility_entry_point_1244.py` | 6 | 269 | #1244 — `vera check` reports the same diagnostics whether it was given a module or a file that imports it. Registration alone says what a module DECLARES; the importer never checked its bodies, so a name a module never imported was rejected standalone (E200, §8.5.1) and accepted in silence through an importer. Written as EQUALITY between the two entry points rather than as "the importer warns", because the property is agreement — a future change making the standalone verdict lenient would satisfy a one-sided assertion and must fail here on the standalone leg. Six cases: the leaked unimported name, the honest control that imports what it uses (green before and after, so the new body check is the visibility rule rather than a blanket rejection of cross-module programs), the issue's type-error-through-importer shape (an `@Int` call bound to a `@Bool` slot: check-clean, Tier-1, failing at compile), a diamond proving each module is reported ONCE (the body check is memoised by path across nested checkers), and both entry points into an import cycle proving the memo terminates it | | `test_clone_body_declaring_module_1241_1243.py` | 5 | 344 | #1241 + #1243 — an imported generic's clone body resolves its bare calls in the DECLARING module, on both sides. The verifier's lexical lookup fell through to the importer's registry (`_declaring_module_scope` swapped the naming env, source and file but not the function registry) and codegen's clone-emission door was the one door that did not thread the module's intra-rename map, so `glib`'s `gen` called the importer's `need`. The two halves are one routing rule, and the tests are written so neither passes alone: each case asserts the `vera verify` verdict AND the `vera run` value together, so the verifier half alone (which makes verify clean while the compiled program still traps on the postcondition it just proved — the measured false Tier-1) fails the same test the codegen half alone (right value, verify still refusing) fails. Every expected value comes from the module verified and run STANDALONE, never from what the importer produces. Shapes: a private direct callee, a two-hop private chain, the type-discriminating pair (`@Int` vs `@Bool` — check-green source that emitted invalid WASM), and the unshadowed-callee control that was correct before and after, which is what pins the defect to the SHADOWED name rather than to cross-module calls in general | @@ -83,7 +85,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_phantom_generic_instances_1271.py` | 14 | 358 | #1271 — discovery inside a still-generic scope must not instantiate a callee at an ENCLOSING scope's type VARIABLE. `pick(@U.1, @U.0)` inside `forall fn helper` bound `pick`'s variable to the NAME `U`, so a `pick$U` clone was emitted whose parameter has no WASM type and which the compilability pass then skipped with a loud `[E604]` — the noise that kept #1223's shapes out of the conformance suite. Drives the four #1223 fixtures plus a mutual-recursion shape (two sibling generic helpers under a generic parent, whose phantoms include one arriving through a callee's declared RETURN type, `leaf$W`), asserting on ONE compile that no clone is keyed by a type variable, that no E602/E604/E605 skip is emitted, AND that the genuinely concrete clone is still there — the third assertion being what separates the filter from an over-filter that would take the real instantiation with it. Plus the **primitive-spelled binder** matrix (`forall`, ``, ``, ``), each row instantiating a sibling at exactly the type its binder is spelled like — a shared `idw(5)` would have let every row but `Int` pass for free — asserted on the clone set AND on the program still running; with the `Q`-binder control that keeps a genuine type variable filtered, so the fix cannot degenerate into "never filter". That control CREATES a live phantom candidate — a generic helper under a generic parent, handing its callee an argument typed by its own binder — because a control that merely fails to create one holds under any filter including none; mutation-checked by disabling the filter, which turns it red | | `test_handle_exn_divergent_result_1276.py` | 10 | 391 | #1276 — a `handle[Exn]` whose clause body AND handled body both diverge emitted a result-LESS `block` into a result-expecting context: check-green, verify-green, rejected at load with `type mismatch: expected i64 but nothing on stack`. Four divergent shapes (the issue's Int rethrow, the `Byte` payload spelling #1269 unmasked, a three-deep rethrow chain, and a clause diverging through both arms of an `if`), each asserted on valid WASM AND on the observable — the OUTER handler's clause value, 1000. Paired with the Unit TWIN, which infers `None` for the same reason but DOES complete: it must keep running and its WAT must contain no `unreachable` at all. The pairing is the point — `result_wt is None` means two things wanting opposite lowerings, and a fix that terminated both would trap a program that runs. The MIRROR family covers a clause that throws on one path and COMPLETES on the other (`if` and `match` spellings), where the inference read only the `then` branch / arm 0, answered `None`, and left the completing path's value stranded in a result-less block; the `if` case appears twice with different thrown values so both the throwing and the completing path are exercised from one inference | | `test_generic_under_generic_callees_1223.py` | 8 | 298 | #1223 — a generic `where`-helper under a GENERIC parent instantiates its own generic callees. The helper is monomorphized only during clone hoisting, outside the worklist that rescans every clone it emits, so a top-level generic called from the helper body was discovered only in its still-generic spelling (`pick$U`, binding the enclosing type variable's NAME) while the rewrite called `pick$Bool` — E602 skip, E620 drop of the parent and of `main`, "No exported functions" from a check-clean, verify-clean program. Four shapes — a user generic, the prelude twin (`option_unwrap_or`), two levels of generic nesting where the INNER helper is the caller, and the non-generic-parent control that compiled before the fix and must keep compiling (it is what proves the trigger is the generic ancestor rather than the nested helper) — each asserted on no E602/E620, the checker's run value (the helper's argument order is non-commutative, so a miswiring gives 7 instead of 3), and a REGISTERED-vs-RESOLVED differential. That differential captures the emitted mono-decl names rather than `_emitted_instances` (whose generic-under-generic entries are keyed by the concrete-free lexical chain, not by the per-clone emission name the rewrite calls) and captures the rewrite side on `_resolve_generic_call` rather than from the WAT, because a desync skips the calling function and removes the dangling `call` along with it. The verifier's half of the pair is pinned in `test_monomorphize_differential.py`'s inline corpus, not here | -| `test_mono_effect_op_naming_1207.py` | 9 | 334 | #1207 — monomorphization discovery and the WASM call-rewrite name ONE clone when an effect operation fixes a generic's type argument. A differential over the two consultors, not a unit test on either: the compiler's own E602 ("call target not registered in this module") IS the two sides disagreeing, so each case asserts no E602/E620, and additionally pins WHICH name they agreed on — an alignment on the wrong one still fails. Four instantiation-driving shapes (`get(())` as an array-literal element under a plain `State` cell, under a `type Count = Nat` alias cell whose clone must be `pick$Count`, in a function whose operation comes from the DECLARED effect row rather than an enclosing `handle`, and in direct argument position), plus the `array_append` builtin-argument control and a shadowed-name control — a user `get(@Unit -> @Bool)` is NOT an effect op in a declared row, so the clone must be `pick$Bool` and not the cell's; that case is green before the fix as well as after, which is what makes it a guard against the alignment over-reaching rather than a second copy of the repro | +| `test_mono_effect_op_naming_1207.py` | 9 | 339 | #1207 — monomorphization discovery and the WASM call-rewrite name ONE clone when an effect operation fixes a generic's type argument. A differential over the two consultors, not a unit test on either: the compiler's own E602 ("call target not registered in this module") IS the two sides disagreeing, so each case asserts no E602/E620, and additionally pins WHICH name they agreed on — an alignment on the wrong one still fails. Four instantiation-driving shapes (`get(())` as an array-literal element under a plain `State` cell, under a `type Count = Nat` alias cell whose clone must be `pick$Count`, in a function whose operation comes from the DECLARED effect row rather than an enclosing `handle`, and in direct argument position), plus the `array_append` builtin-argument control and a shadowed-name control — a user `get(@Unit -> @Bool)` is NOT an effect op in a declared row, so the clone must be `pick$Bool` and not the cell's; that case is green before the fix as well as after, which is what makes it a guard against the alignment over-reaching rather than a second copy of the repro | | `test_effect_op_determinism.py` | 9 | 504 | #1215 — bare effect-op resolution order: the built-in `State` and `Http` both declare `get`, so `effects(, Http>)` is a two-candidate row with no user `effect` declaration needed. The two candidate bindings are made to produce DIFFERENT observables (the source-order program runs to `70`; the reversed row is a loud `E217` naming `Http.get`), swept across six `PYTHONHASHSEED` values in child interpreters so a frozenset-order flip cannot pass — plus the innermost-handler-beats-declared-row precedence case, a signature-level assertion that the resolved `OpInfo` follows the recorded order both ways (including the deterministic name tiebreak for a row member no order tuple mentions), and the qualified-lookup control. The type-ARGUMENT sibling rides here too: `effects(, State>)` (two independent cells, spec §7.3.3) had the identical frozenset dependence in `_effect_type_mapping`, and codegen took the LAST instantiation in the row where the checker takes the first — both now source-order-first, swept the same way. A sixth sweep covers the public `ordered_effect_row()` fallback for a row member no order tuple mentions: its two members share the effect NAME and differ only in type ARGUMENT, so a name-only sort key ties them and hands back frozenset order — the order AND the `_effect_type_mapping` selection it drives are both asserted stable across the same seeds. Two further sweeps take that structural key down a level: a type argument may itself be a FUNCTION type, whose own effect row was rendered by `pretty_effect` — so two outer instances differing only INSIDE a nested row (by a refinement's predicate, or by a type variable's built-in marker) tied again, and both legs are asserted single-outcome across the same seeds, separately, so a regression names the elision that came back | | `test_db_effect.py` | 9 | 136 | #229 — the built-in `` effect: `DB.query` / `DB.execute` type-check under `effects()` (E122 without it; E204 on a non-`String` SQL argument), plus `is_db_sql_op` — the predicate the #309 gate keys on — gating any `DB.query`/`DB.execute` by `parent_effect == "DB"` + op name (the same axis codegen routes to the host on), so a user `effect DB` shadow's op IS gated (it would still reach the host) while an unrelated effect's `query` is not — the shadow is itself rejected at its declaration since #1149 (E152), so this predicate is defence in depth; a checker↔codegen differential pins the gated set to the built-in DB ops | | `test_db_marshalling.py` | 35 | 234 | #229 — the `` marshalling helpers: `Array>` params (inbound reader), `Array>>` query grids (`_alloc_result_ok_rows`) and `Result` row-counts, round-tripped through an `InstanceCaller` over a real compiled module — each case run normally AND under `VERA_EAGER_GC=1` (every `$alloc` fires `$gc_collect`), the large-grid case forcing free-block reuse; mutation-validated (dropping a shadow-stack root corrupts the read-back / SIGBUSes the swept-pointer read) | @@ -93,7 +95,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_checker_errors.py` | 73 | 1,196 | Error codes, resolution-coverage diagnostics, contracts, error accumulation (#420 split); cyclic type aliases incl. #1059 self-reference through a type argument (`Future`, mutual `Future`/`Future`, `Array`) rejected E132 | | `test_checker_builtins_collections.py` | 97 | 848 | Map / Set / Decimal / Json / Html / Http / Inference built-in type-checking (#420 split) | | `test_checker_builtins_strings.py` | 122 | 945 | String / numeric / type-conversion / float-predicate / string-search / markdown / regex built-in type-checking, removed-legacy-name regression (#420 split) | -| `test_obligations.py` | 710 | 1,741 | Reified proof obligations + warm `VerificationSession` (#222 Phase A): full-corpus differential oracle (warm session == cold `verify()` on diagnostics, summary, and obligation stream, plus warm-twice determinism, across all 42 examples and every verify/run-level conformance program), summary↔obligation tier-bookkeeping consistency (including the #967 `total == tier1_verified + tier3_runtime` leg, plus a focused self-consistency pin on the three call-demotion examples), the #1242 stream partition — over a corpus widened to every conformance program that type-checks, at any level, `len(obligations) == total + violated + tier3_unguarded` and every status is one of the documented five, with the vocabulary read from the `ObligationStatus` Literal so a sixth member fails rather than vanishing from the counts — per-kind unit tests (requires / ensures / decreases / nat_sub / call_pre statuses, counterexamples, error codes), content-key stability + same-text-two-sites span disambiguation, session solver reuse, type-error short-circuit, ADT-registry resync between programs; plus the Phase B incremental suite — identical-source full replay, callee-body-edit replays callers while callee-contract-edit invalidates them, span-shift and ADT-edit conservative invalidation, cross-program isolation, timeout-status never cached (monkeypatched solver), FIFO eviction bound; plus the #727 dedup pin — a violating call in a let RHS records exactly one E501 diagnostic and one call_pre obligation; plus the #1208 call-site rendering pin — a PARAMETERISED callee slot substitutes into the E501 message and its fix instead of falling back to the generic wording | +| `test_obligations.py` | 716 | 1,741 | Reified proof obligations + warm `VerificationSession` (#222 Phase A): full-corpus differential oracle (warm session == cold `verify()` on diagnostics, summary, and obligation stream, plus warm-twice determinism, across all 42 examples and every verify/run-level conformance program), summary↔obligation tier-bookkeeping consistency (including the #967 `total == tier1_verified + tier3_runtime` leg, plus a focused self-consistency pin on the three call-demotion examples), the #1242 stream partition — over a corpus widened to every conformance program that type-checks, at any level, `len(obligations) == total + violated + tier3_unguarded` and every status is one of the documented five, with the vocabulary read from the `ObligationStatus` Literal so a sixth member fails rather than vanishing from the counts — per-kind unit tests (requires / ensures / decreases / nat_sub / call_pre statuses, counterexamples, error codes), content-key stability + same-text-two-sites span disambiguation, session solver reuse, type-error short-circuit, ADT-registry resync between programs; plus the Phase B incremental suite — identical-source full replay, callee-body-edit replays callers while callee-contract-edit invalidates them, span-shift and ADT-edit conservative invalidation, cross-program isolation, timeout-status never cached (monkeypatched solver), FIFO eviction bound; plus the #727 dedup pin — a violating call in a let RHS records exactly one E501 diagnostic and one call_pre obligation; plus the #1208 call-site rendering pin — a PARAMETERISED callee slot substitutes into the E501 message and its fix instead of falling back to the generic wording | | `test_verifier_contracts.py` | 95 | 898 | Z3 verification over the example corpus, trivial/ensures/if-else/let/multi-clause contracts, counterexamples, tier classification, arithmetic, verification summaries, Diverge effect, edge cases, string-length + string-predicate verification (#839 split) | | `test_verifier_nat_obligations.py` | 82 | 1,743 | **`@Nat` subtraction underflow obligation** (#520 — Path-A discharge via requires/path-conditions/path-aware Z3 refutation, pure-literal exclusion, Int-Int and Nat-Int exemptions) and **`@Nat` binding-site narrowing obligation** (#552/#747/#749 — Tier-1 `value >= 0` at let/call-arg/effect-op-arg/ctor-field/match-bind/destructure narrowing — a concrete site classifies `tier3_runtime` (codegen-guarded) while the effect-op argument and generic-instantiated constructor field classify `E504` (obligated but unguarded, #754/#757) whose rationale names its actual cause — an untranslatable value — rather than the untranslatable-or-timeout conflation #1251 removed, walker-recursion pins, `_narrows_into_nat` verifier/codegen soundness parity; PR #972 clone-instantiated side-table substitution — a `Some(@T)` bind in an `Option`-instantiated clone is no narrowing, genuine clone-path narrowings still obligated); #1201 — a builtin `Tuple` parameter's match-bound components carry their declared component facts (a valid ensures over one proves instead of falsely violating) and an `Int` component bound as `@Nat` fires one loud `E503` per component, both mutation-caught (#839 split) | | `test_verifier_primitive_ops.py` | 39 | 662 | **Primitive-operation safety obligations** (#680) — division/modulo by-zero `E526` and array-index-bounds `E527`, the in-bounds/out-of-bounds two-check with float-exemption, honest Tier-3 for opaque lengths, off-by-one and lower-bound pins, De Bruijn-correct fix hints (#839 split) | @@ -111,11 +113,11 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_soundness_392.py` | 36 | 584 | #392 audit batches 1–2 — verifier soundness/completeness fixes: signed div/mod truncate toward zero (#799), body `assert(P)` carries a Tier-1 obligation (#800), divisions in contract predicates carry a `div_zero` obligation (#801), and the #804 assume-half of #800's `assert` rule — a prior `assert`/`assume` discharges later obligations (including a later call's precondition) + the postcondition at Tier 1, removing false E501/E503/E500/E505 | | `test_int_overflow.py` | 6 | 143 | #798 — `@Int`/`@Nat` arithmetic-overflow obligations (part of the #392 `smt.py` soundness audit): `+`/`-`/`*` on `@Int`/`@Nat` now emit an `int_overflow` obligation (the analog of `nat_sub`/`div_zero`) rather than modelling the operands as Z3's unbounded integers, so a `ensures(@Int.result > @Int.0)` over `@Int.0 + 1` no longer proves a contract the i64/u64 runtime violates under two's-complement wraparound. Unbounded operands leave the obligation undischarged (Tier-3, runtime-guarded); operand bounds that prove the result stays in range discharge it at Tier 1 | | `test_int_overflow_codegen.py` | 62 | 718 | #798 Stage 3 — runtime overflow-trap codegen: the codegen emits a guard at *exactly* the `@Int`/`@Nat` `+`/`-`/`*` sites the verifier obligates, so `vera run`/`vera compile` programs trap on overflow instead of silently wrapping at the i64/u64 boundary. #808 wired the guard to the `vera.overflow_trap` host import, so the trap now classifies `kind="overflow"` (carrying the overflow Fix paragraph) rather than the generic `unreachable`; `TestOverflowTrapKind808` pins that, with controls proving the #520 `nat_sub` underflow and #813 `@Nat`→`@Int` widen guards still classify `unreachable` | -| `test_int_overflow_differential.py` | 239 | 398 | #798 Stage 3 verifier↔codegen classification differential (cross-component soundness rule): the codegen overflow guard must fire at exactly the sites the verifier obligates *and* classify each site's operand type (`@Int` i64 vs `@Nat` u64) identically — else a Tier-1-clean program traps spuriously or a wrapping op slips through unguarded. Over a corpus exercising all five operand combos plus the literal-left ambiguity (a naive codegen mis-classifies it as `@Nat`), asserts the verifier's per-site gated classification equals the codegen's site for site, both sides driven by the same `ast.span_key` | +| `test_int_overflow_differential.py` | 241 | 398 | #798 Stage 3 verifier↔codegen classification differential (cross-component soundness rule): the codegen overflow guard must fire at exactly the sites the verifier obligates *and* classify each site's operand type (`@Int` i64 vs `@Nat` u64) identically — else a Tier-1-clean program traps spuriously or a wrapping op slips through unguarded. Over a corpus exercising all five operand combos plus the literal-left ambiguity (a naive codegen mis-classifies it as `@Nat`), asserts the verifier's per-site gated classification equals the codegen's site for site, both sides driven by the same `ast.span_key` | | `test_nat_int_widening.py` | 36 | 602 | #813 — `@Nat -> @Int` widening coercion obligation (dual of #552 `nat_bind`, part of the #392 soundness audit): a `@Nat` in (i64.MAX, u64.MAX] reinterprets when widened (u64.MAX → -1), so a `nat_to_int_coerce` obligation that the value is `<= i64.MAX` now fires at the return position — provably-in-range → Tier-1, provably-out-of-range (`@Nat.0 >= 2**63`) → loud E530, unbounded → honest Tier-3 (runtime-guarded), with an `@Int -> @Int` control that must not fire; the unguarded generic-`@Int`-field case also has its `E531` rationale read for WHAT IT SAYS — a value bounded on neither side, not the untranslatable-or-timeout conflation #1251 removed. The #813 follow-up adds the explicit `nat_to_int` built-in and heterogeneous `if`/`match` arms with a non-negative-literal alternative; #820 adds the heterogeneous-`@Int`-slot arm, closure argument, and closure return/capture obligations (each per-arm / per-site, with `@Int`-arm and `@Nat`-formal controls that must not fire) | | `test_int_widening_codegen.py` | 52 | 535 | #813 Stage 3 — runtime `@Nat -> @Int` widening-trap codegen: the codegen emits a guard at *exactly* the `@Nat -> @Int` coercion sites the verifier obligates (return, `let`, call argument, and — since #820 — array element, tuple construction/destructure, heterogeneous `if`/`match` arm, closure argument/return), so `vera run`/`vera compile` programs trap when a `@Nat` above i64.MAX would reinterpret to a negative `@Int` instead of silently returning the wrong value. The trap is a bare `unreachable` (shares `_emit_negative_i64_guard` with the #552 nat-bind guard), classified `kind="unreachable"` today (a dedicated widening trap kind is a follow-up) | | `test_int_widening_differential.py` | 26 | 320 | #813 verifier↔codegen behavioural differential (cross-component soundness rule): at every `@Nat -> @Int` coercion site the verifier's `nat_to_int_coerce` classification must AGREE with the runtime — a `tier3` (codegen-guarded) site MUST trap on a `@Nat` above i64.MAX (return / `let` / call-arg / constructor field / ADT sub-pattern / match-bind, and the #820 array-element / tuple-component / heterogeneous-arm / closure argument-return sites), while a `tier3_unguarded` (E531) site must NOT trap (the generic-instantiated `@Int`-field coercion codegen cannot guard). Runs BOTH sides on one corpus so the "runtime-guarded" claim is checked against the actual trap — catching a verifier deferral codegen never guards (unsound silent -1) or a spurious trap | -| `test_nat_narrowing_return_differential.py` | 136 | 2,883 | #758 verifier↔codegen behavioural differential (cross-component soundness rule): at the function-return `@Int -> @Nat` coercion slot the verifier's `nat_bind` verdict must AGREE with the runtime — an unproven narrowing leaves the return obligation undischarged (loud E503, or an honest `tier3` for an opaque value) and codegen's return guard TRAPS on a negative input, while a proven narrowing (`requires` / path condition) discharges at Tier 1 and the guard is dead (`vera run` returns the value, no trap). Runs BOTH sides on one corpus so "the verifier obligates this return" is checked against the actual guard — the return-position dual of `test_int_widening_differential`. #983 review adds the `tier3` quadrant (opaque `float_to_int`, verify + compile in one run), `let_before_tail` / `nested_if_join` join shapes, a `type Count = Nat` alias case, and threads `file=` + `resolved_modules=` through the verify side for CLI-pipeline fidelity. #1017 adds the `apply_fn` ARGUMENT-narrowing quadrant (the `@Int -> @Nat` dual of the #820 argument widening): a provably-negative arg is E503, a runtime arg is obligated + `call_indirect`-guarded (run traps), a `requires`-bounded arg proves Tier 1, a `@Nat -> @Nat` arg is unobligated, and an opaque `float_to_int` arg records `tier3` with the codegen `i64.lt_s`/`unreachable` guard emitted (verify + compile cross-checked in one pipeline run). #1024 adds the REFINED apply_fn-argument quadrant (`refine_bind`, the refinement dual of #1017): an argument narrowing into a `{@Nat \| @Nat.0 > 0}` closure formal discharges the FULL predicate refined-first — a constant `0` is E505 (clears the `@Nat` base's `>= 0` but violates `> 0`), a runtime arg is obligated + guarded at the lifted closure's prologue (run(0) traps with a `contract_violation` Refinement-violation message), a constant `5` / `requires`-bounded arg proves Tier 1, and a `@Pos -> @Pos` arg is unobligated. #1032 adds the REFINED closure-RETURN quadrant (the return-side dual of #1024): `fn(@Int -> @Pos) { @Int.0 }` records exactly one tier3 `refine_bind` (opaque body — never a false Tier 1), run(-5) AND run(0) trap at the lifted body's return guard with the "return value" refinement message, a satisfying value passes, and the always-satisfying body stays an honest tier3 with no spurious trap — plus the re-derived single-guard pin (exactly one `contract_fail` refinement check in the lifted body, zero `i64.lt_s` narrowing checks). PR #1202 adds the #1203 handler-boundary quadrants (init/put/with/resume × trap/pass/zero, bare-put and clause-body-put dispatch shapes, widen duals at U64_MAX with i64.MAX boundary controls) and the #1205 scalar-alias family quadrants: alias and refined-alias `State` cells compile and run, every #1203 guard keys through the alias (init/put trap on negatives, widen dual at U64_MAX), the alias-equal annotation binds clause slots under its SOURCE name, a stateless handler's clause `@T.0` reaches the op ARGUMENT (the pre-fix capture skew read the cell — pinned in both directions), `Exn` compiles with the payload bound under the clause pattern's name, `old(State)` snapshots through the collapsed family, and the retired lying-annotation fixture is pinned as check-rejected E336. The second adversarial round adds the clause-scope checker-parity battery (mixed-spelling State and Exn shapes bind under the checker's canonicalized argument names, both patternless twins bind nothing, the declaration-scope shadow probe), the parameterised-alias family differentials (`State>`, alias-of-generic-alias, `Exn>`), and the `State` write-boundary battery (init/clause-put/bare-put/`with`/resume literals at i32) | +| `test_nat_narrowing_return_differential.py` | 136 | 2,892 | #758 verifier↔codegen behavioural differential (cross-component soundness rule): at the function-return `@Int -> @Nat` coercion slot the verifier's `nat_bind` verdict must AGREE with the runtime — an unproven narrowing leaves the return obligation undischarged (loud E503, or an honest `tier3` for an opaque value) and codegen's return guard TRAPS on a negative input, while a proven narrowing (`requires` / path condition) discharges at Tier 1 and the guard is dead (`vera run` returns the value, no trap). Runs BOTH sides on one corpus so "the verifier obligates this return" is checked against the actual guard — the return-position dual of `test_int_widening_differential`. #983 review adds the `tier3` quadrant (opaque `float_to_int`, verify + compile in one run), `let_before_tail` / `nested_if_join` join shapes, a `type Count = Nat` alias case, and threads `file=` + `resolved_modules=` through the verify side for CLI-pipeline fidelity. #1017 adds the `apply_fn` ARGUMENT-narrowing quadrant (the `@Int -> @Nat` dual of the #820 argument widening): a provably-negative arg is E503, a runtime arg is obligated + `call_indirect`-guarded (run traps), a `requires`-bounded arg proves Tier 1, a `@Nat -> @Nat` arg is unobligated, and an opaque `float_to_int` arg records `tier3` with the codegen `i64.lt_s`/`unreachable` guard emitted (verify + compile cross-checked in one pipeline run). #1024 adds the REFINED apply_fn-argument quadrant (`refine_bind`, the refinement dual of #1017): an argument narrowing into a `{@Nat \| @Nat.0 > 0}` closure formal discharges the FULL predicate refined-first — a constant `0` is E505 (clears the `@Nat` base's `>= 0` but violates `> 0`), a runtime arg is obligated + guarded at the lifted closure's prologue (run(0) traps with a `contract_violation` Refinement-violation message), a constant `5` / `requires`-bounded arg proves Tier 1, and a `@Pos -> @Pos` arg is unobligated. #1032 adds the REFINED closure-RETURN quadrant (the return-side dual of #1024): `fn(@Int -> @Pos) { @Int.0 }` records exactly one tier3 `refine_bind` (opaque body — never a false Tier 1), run(-5) AND run(0) trap at the lifted body's return guard with the "return value" refinement message, a satisfying value passes, and the always-satisfying body stays an honest tier3 with no spurious trap — plus the re-derived single-guard pin (exactly one `contract_fail` refinement check in the lifted body, zero `i64.lt_s` narrowing checks). PR #1202 adds the #1203 handler-boundary quadrants (init/put/with/resume × trap/pass/zero, bare-put and clause-body-put dispatch shapes, widen duals at U64_MAX with i64.MAX boundary controls) and the #1205 scalar-alias family quadrants: alias and refined-alias `State` cells compile and run, every #1203 guard keys through the alias (init/put trap on negatives, widen dual at U64_MAX), the alias-equal annotation binds clause slots under its SOURCE name, a stateless handler's clause `@T.0` reaches the op ARGUMENT (the pre-fix capture skew read the cell — pinned in both directions), `Exn` compiles with the payload bound under the clause pattern's name, `old(State)` snapshots through the collapsed family, and the retired lying-annotation fixture is pinned as check-rejected E336. The second adversarial round adds the clause-scope checker-parity battery (mixed-spelling State and Exn shapes bind under the checker's canonicalized argument names, both patternless twins bind nothing, the declaration-scope shadow probe), the parameterised-alias family differentials (`State>`, alias-of-generic-alias, `Exn>`), and the `State` write-boundary battery (init/clause-put/bare-put/`with`/resume literals at i32) | | `test_hetero_widen_tailcall.py` | 21 | 312 | The heterogeneous per-arm widen guard vs tail calls (#986) and targets: an arm whose `@Nat` value is a tail call must lower to a plain `call` so the appended guard stays live (`return_call` would skip it — the widening dual of the #983 per-leaf narrowing), the genuine `@Int` arm's recursive `return_call` keeps TCO (100k-depth run), the gate is target-aware (`_is_hetero_int_widen_join`: a hetero join in a `@Nat`-returning context must NOT widen-guard its legal `@Nat` arm — the target-blind gate false-trapped 2^63), and a user `data Tuple` must not take the builtin variadic carrier's target-table path (verifier emits no obligation there; guarding it was an opposite-direction desync) | | `test_xmod_span_collision.py` | 4 | 156 | #987 — the span-keyed target-type table is single-module (keyed by bare span, no file identity): an imported body's expression span can coincide with a main-file entry. #987 threads each module's OWN table into codegen (`CheckArtifacts.module_artifacts` → `_compile_fn(module_tables=...)`), so the engineered line-for-line collision pair now proves the legal all-`@Nat` imported function is not falsely widen-guarded by CORRECTNESS (its own table targets `Tuple`), not merely suppression — with a `thread_modules=False` control pinning the #986 suppression fallback still holds when no module tables are threaded, and a same-file control proving top-level guards unaffected | | `test_xmod_widening_differential.py` | 18 | 293 | #987 verifier↔codegen widening differential run THROUGH THE IMPORT DOOR (the same-file `test_int_widening_differential` was green while this door was open): for each cross-module shape (array-element, tuple-construction, tuple-destructure control, transitive 3-level, and shadowed-import) the library's standalone verify must classify the `@Nat -> @Int` coercion Tier-3, AND the importing program compiled the way `vera run`/`vera compile` compile it (per-module tables threaded) must TRAP at `u64.MAX` — never the silent -1 — while passing `2^63-1` and `42` unchanged. Pins that the #820 array/tuple-construction guards, recovered from the span-keyed target table, now fire for imported bodies. Also pins the import-door trap is the guard's bare `unreachable` net (not some other trap), and a two-independent-libraries-both-widen scenario asserting BOTH imported bodies trap at `u64.MAX` (kills a first-module-only partial-collection mutant) | @@ -177,7 +179,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_string_length_soundness.py` | 15 | 278 | #802 — string_length code-point vs UTF-8 byte soundness: a non-literal `string_length` defers to Tier 3 (the issue's `"é"` probe no longer proves `== 1` at Tier 1), a string-literal length is modeled at its exact UTF-8 byte count (`== 2` for `"é"`), and the boolean predicates `string_contains` / `string_starts_with` / `string_ends_with` stay Tier 1 (sound under UTF-8 self-synchronization), while a predicate over an astral (> U+2FFFF) or lone-surrogate literal defers to Tier 3 (z3.StringVal cannot model those code points) | | `test_errors.py` | 62 | 657 | Error code registry, diagnostic formatting, serialisation, SourceLocation, and error display sync — the canonical `E001` diagnostic must match each of its mirrors: `README.md`, `docs/index.html`, `spec/00-introduction.md`, `AGENTS.md`'s example `--json` block, and the hardcoded example in `scripts/build_site.py` that generates `docs/index.md` (#829; `AGENTS.md`'s ellipsis-truncated description/rationale are prefix-compared, its `error_code`/`spec_ref`/`fix` exactly) | | `test_eq_contract_874.py` | 13 | 430 | `eq`/`compare` ability ops in contract position: codegen canonicalization + verifier Tier-1 discharge/counterexample, where-fn contracts, compare Ordering-sort materialization, shadowing guard (#874) | -| `test_formatter.py` | 509 | 3,638 | Comment extraction, interior comment positioning, expression/declaration formatting, match arm block bodies, §1.8 rule 2 in value position (a `let`-bound `match`/`if` expands exactly as one in statement position, and a comment above an arm inside a statement's value stays on that arm), blank-line preservation (§1.8 rule 13 — gaps between statements, before a block result and around a comment, collapsed to one and never invented), idempotency, parenthesization, spec rules, ability declarations | +| `test_formatter.py` | 511 | 3,638 | Comment extraction, interior comment positioning, expression/declaration formatting, match arm block bodies, §1.8 rule 2 in value position (a `let`-bound `match`/`if` expands exactly as one in statement position, and a comment above an arm inside a statement's value stays on that arm), blank-line preservation (§1.8 rule 13 — gaps between statements, before a block result and around a comment, collapsed to one and never invented), idempotency, parenthesization, spec rules, ability declarations | | `test_cli.py` | 273 | 4,612 | CLI commands (check, verify, compile, run, serve, test, fmt, version, quiet), subprocess integration, JSON error paths (including the `verify --json` `obligations` array and its summary-reproducibility pin, #967, and the #1242 partition pin — the array is emitted unfiltered, a refuted obligation is counted by no summary field, and it still joins its E500 on the location key), runtime traps, arg validation, multi-file resolution, IO exit codes, --explain-slots (including the #1208 naming pins — an alias in type-argument position is tabled resolved, and a `forall` variable shadowing a module alias keeps the two parameter stacks apart — and the #1217 `where`-helper tables: the helper prints indented under its parent, appears in the JSON qualified as `parent.helper`, and inherits the enclosing `forall` variables so the shadowing holds inside it too), `builtins`/`effects`/`errors` introspection dispatch, and a USAGE-completeness guard (every dispatched `cmd_` handler has a help row) | | `test_introspect.py` | 39 | 221 | `vera builtins/effects/errors --json` registry introspection (#539): the `{schema, items}` envelope, count-equals-registry differential per registry, error-phase derivation, effect/ability `kind` tagging, the parameterised `Exn` effect, and best-effort `since` attribution with full-coverage guards | | `test_resolver.py` | 20 | 602 | Module resolution, path lookup, parse caching, circular import detection, the E011/E012/E013 diagnostic contract, internal-error isolation (a compiler bug is not masked as E013), and the transitive-closure return of `resolve_imports` (#890 — a diamond yields each reachable module once, direct imports tagged `direct`, the transitive one not) | @@ -195,7 +197,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_markdown.py` | 59 | 393 | Markdown parser: block/inline parsing, rendering, round-trips, edge cases | | `test_lsp.py` | 146 | 2470 | LSP transport + coordinate layer (#222 Phase C) and language features (#222 Phase D): parametrized code-point↔UTF-16 goldens incl. astral-plane fixtures and surrogate-pair snapping, Span (1-based, exclusive-end) and SourceLocation (0-based col) → LSP Range conversions, point→token-range widening, DocumentStore open/change/close + index invalidation, an in-process handler-drive test, and one stdio end-to-end round-trip against the real `vera lsp` subprocess (initialize → didOpen → shutdown → exit) pinning serverInfo + textDocumentSync capabilities; plus the Phase D feature suite — parse-error single-diagnostic path, type-error verification short-circuit, tier=3 in E520 diagnostic data, per-function tier Hint synthesis (and its suppression for functions with violated obligations), smallest-enclosing-span hover, De Bruijn slot goto (most-recent-parameter jump, out-of-range None, off-slot None, and the #1208 keying pins: a parameterised reference resolves, an alias-spelled parameter is reachable from a canonically-spelled reference, and a `forall` variable shadowing a module alias lands on the right parameter), and typed-hole completion (inside/after hole, away-from-hole None); plus the Phase E speculativeEdit suite — identical-text all-unchanged, breaking edit surfaces newly_undischarged (violated nat_sub) with canonical state untouched, strengthening edit surfaces newly_discharged, parse/type errors report ok:false, deleted functions report removed, proof_delta purity; plus the Phase F1 proposeEdit suite — the apply gate (clean and strengthening edits apply, breaking and non-compiling edits refuse), force overriding both gates with the delta still reported, wiring against a structural fake server (apply round-trip with exact full-document replacement range, refuse touches no canonical state, unopened-URI clamp sentinel), and full-document-range goldens (trailing-newline virtual line, UTF-16 end column); plus the Phase F2 strengthenContract suite — splice goldens (first-clause-only replacement with byte-identical remainder, ensures variant, unknown-fn None), the call-site audit pin (tightened precondition refused with newly_undischarged call_pre items, canonical state untouched), provable-ensures strengthening applies, and the three splice-target refusal paths (no analysis, unparseable document, unknown function); plus the Phase F3 addEffect suite — transitive-caller closure goldens (diamond in declaration order, leaf, unknown-fn None, recursion appears once), handler bounding (#725: a caller discharging the effect around its only call site drops out of the closure and is not rewritten, while a second unhandled path, a call in a handler clause, a handler for another effect, a handler naming a different instance of the same effect (`handle[State]` against a `State` propagation, which the checker does not discharge — end-to-end that caller must still be rewritten and the candidate must still apply, with the matching `State` propagation against the same fixture as the positive control that this handler key does prune something), and a bare `where`-helper call all keep it in, as does a refinement type argument at either depth (`Exn<{ @Int \| p }>` and `Exn>` render as their bare base type but discharge nothing of `Exn` — also pinned end-to-end), while an unparameterised `handle[IO]` does bound an `IO` propagation, a whitespace-spelled `State< Int >` request still bounds, and nesting bounds in either order (a matching handler inside a foreign one, a foreign one inside a matching one) — plus a key-level pin that `handle[Mod.IO]` keeps its module, and the effect-less query pinned handler-unaware; plus the two boundary pins the review added — a call in the handler's STATE INITIALISER keeps its edge, since the initialiser is evaluated in the enclosing scope before the handler is installed (pinned beside the E125 the checker raises there against a `pure` caller, with the identical call in the handler body clean as the contrast), and an alias-spelled handler does not bound a `State` propagation though the checker discharges it (`handle[State]` with `type MyAlias = Int` — the spelling comparison's under-prune, #1292, with the alias-spelled request as the control that does prune)), effect-row rewrite goldens (pure to singleton set, source-preserving append, already-present None, base-name identity blocking State next to State), diamond propagation applying one multi-site candidate with the bystander untouched, mixed append/replace rows with already-satisfied callers skipped, the fully-satisfied no-op shape, and the two refusal paths; plus the #728 instruction-contract suite — the LSP message carries description, rationale, and the Fix: paragraph (also pinning single E501 emission at the LSP surface), and a bare diagnostic maps to the description alone | | `test_browser.py` | 173 | 3,858 | Browser parity: Python/wasmtime vs Node.js/JS-runtime output equivalence across IO, State, contracts, Markdown, Regex, and the examples the browser target can execute (two explicit lists in the file, not the whole `examples/` directory — interactive stdin, file IO, `DB` and the non-standalone `modules` example are excluded with their reasons recorded); plus the #349 `runtime.mjs` coverage battery — per-value-type and per-key-type Map variants, per-element-type Set variants, cold `Decimal` branches (exact-zero sign, negative-shift division, `decRoundPlaces` special cases, non-finite storage), `readJson`/`json_stringify` across every Json ADT tag, the Regex/Json `Result.Err` arms, and nested-Markdown walks. Two cases pin browser↔native divergences the coverage work uncovered by asserting each runtime's exact output separately — both are tracked bugs, not deliberate boundaries — `json_stringify` (bare `JSON.stringify` vs `json.dumps`: separator padding and integral-number rendering, [#1293](https://github.com/aallan/vera/issues/1293)) and `md_render` on **any multi-line paragraph**, where the browser keeps the paragraph's internal soft line break and does not re-apply the container prefix, its render is not stable under re-render, and a blockquote wrapping a heading and a fenced block is destroyed outright the second time round ([#1294](https://github.com/aallan/vera/issues/1294)) — so a change on either side goes red instead of being absorbed as an expected failure | -| `test_conformance.py` | 1070 | 125 | Parametrized conformance suite: parse, check, verify, run, format idempotency across 214 programs | +| `test_conformance.py` | 1080 | 125 | Parametrized conformance suite: parse, check, verify, run, format idempotency across 216 programs | | `test_prelude.py` | 29 | 585 | Prelude injection: Option/Result/array operation detection, combinator shadowing, type aliases, the reserved namespace every injected alias declaration lives in — checked against the checker's own E154 regex rather than a second spelling of the rule, since an alias the prelude injects outside it is one codegen resolves and the checker leaves opaque (#1184/#1221) — end-to-end compilation | | `test_checker_apply_fn.py` | 18 | 455 | #854 — `apply_fn` as a checker special form: zero-warning pins (API + CLI `--json` + closures.vera), E201 arity / E202 type / non-function-first-arg errors, E122/E125 effect-row enforcement for applied fn values, E151 redefinition rejection, variadic two-param application, prelude combinator regression pins | | `test_prelude_diagnostics.py` | 8 | 271 | #851 — prelude combinator skip-warnings: unreferenced-prelude E602/E604 suppression (zero-warning minimal compile, API + CLI `--json`), `` origin attribution for referenced-but-skipped combinators (text + `to_dict`), transitive reference scan, and user-fn warning locations pinned unchanged | @@ -216,11 +218,11 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_doc_builtin_shadowing.py` | 8 | 107 | `check_doc_builtin_shadowing.py` gate ([#819](https://github.com/aallan/vera/issues/819)): reject-set membership (opaque built-ins in, overridable combinators out), top-level + `where`-block `fn ` definitions flagged, non-built-in / overridable / prose-mention ignored, and the shipped docs are currently clean | | `test_runtime_traps.py` | 75 | 2,877 | Runtime trap categorisation (#516 Stage 1), out-of-bounds host-read bounds check (#1145), stdout/stderr-on-trap preservation (#522), `IO.print` live tee (#543), and trap source backtrace (#516 Stage 2): `_classify_trap` per-`kind` mapping (`divide_by_zero`/`out_of_bounds`/`stack_exhausted`/`unreachable`/`overflow`/`contract_violation`/`unknown`), `WasmTrapError` shape + `RuntimeError` substitutability, end-to-end `cmd_run` text + JSON envelopes including `trap_kind`, captured `stdout`, captured `stderr`, JSON-mode "no stderr leak" invariant, cross-stream code-order regression using merged `redirect_stdout`/`redirect_stderr`, the v0.0.123 tee suite (live streaming, write-count + order preservation, JSON-mode tee suppression, trap preservation invariant under tee, per-write flush count, default-execute silence), and the v0.0.124 source-mapping suite — `_resolve_trap_frames` unit tests covering user-fn / built-in / built-in-prefix / monomorphized base-name fallback / unknown-name / no-frames-attribute / leaf-first ordering preservation; end-to-end `cmd_run` text-mode + JSON-mode backtrace including the **leaf-first** ordering invariant; contract-violation backtrace in both text and JSON modes; direct `execute()` `WasmTrapError.frames` attachment; **suppression marker** for collapsed leading runtime-helper frames (mocked `vera.codegen.execute` with synthetic `is_builtin=True` leaf frames so the collapse logic is testable deterministically); source-map population for top-level fns + lifted closures (with span-value assertion against the closure literal's exact line range); and the no-builtin-leakage regression that pins built-in helpers (`alloc` / `gc_collect` / `contract_fail`) NOT being registered in `fn_source_map`; plus the v0.0.125 Stage 3 suite (`#547`) — text-mode `Fix:` block surfacing with position-ordering invariant (Fix appears after the source backtrace), text-mode block suppression for `contract_violation` (no empty header noise), JSON-mode `fix` field always-present (schema stability) including the empty-string case, `_TRAP_FIX_PARAGRAPHS` table-completeness assertion (every kind in the taxonomy has a Fix paragraph entry), and the column-wrap invariant (~76 chars max per line, two-space indent under the `Fix:` heading); plus the UTF-8 hardening suite **`TestHostPrintInvalidUtf8589`** (`#589` / `#592`) — after `#592` centralised the `errors="replace"` invariant into the single `vera.runtime.text.safe_utf8_decode` helper — reached only through a shared `_slice_and_decode` helper (`vera/runtime/heap.py`) that the three WASM-memory string readers (`_read_wasm_string` and `_read_string_export` there, and `vera/wasm/markdown.py::_read_string`) delegate to, with the `host_print` / `host_stderr` / `host_contract_fail` host imports and the String-return extractor in `execute()` routing through those readers rather than decoding inline: one helper unit test pinning the invariant once (invalid bytes → U+FFFD, valid + empty pass through), three wire-real end-to-end tests that drive the **production** readers (`_read_wasm_string` / markdown `_read_string` behind a synthetic-WAT `probe` host import; `_read_string_export` against a real exported memory, also covering its out-of-bounds → `None` pointer-fallback) over a region seeded with invalid UTF-8 — so a strict-decode regression surfaces as a `UnicodeDecodeError` escaping wasmtime's trampoline, and the host imports / extractor are transitively covered — and one synthetic-WAT end-to-end test that imports `vera.print` and calls it with raw invalid UTF-8 bytes to pin the wasmtime-trampoline fact independently (a Python `UnicodeDecodeError` inside a host import escapes as a "python exception" cause iff the host decode is strict); the six pre-`#592` structural source-grep assertions were retired by the centralisation; plus the Ctrl-C-during-host-import suite **`TestHostSleepKeyboardInterrupt`** ([#595](https://github.com/aallan/vera/issues/595) / [#599](https://github.com/aallan/vera/issues/599)) — after the v0.0.160 relocation to a single `except KeyboardInterrupt` handler in `execute()` (enabled by `wasmtime>=45.0.0`'s `except BaseException` trampoline fix): one structural assertion that the four per-host-import `raise _VeraExit(130)` guards are gone and the centralized handler maps to `exit_code=130`, plus four end-to-end tests that compile real Vera programs calling `IO.sleep(...)`, `IO.read_char(())`, a mocked fused `await` ([#841](https://github.com/aallan/vera/issues/841) — `Future.result()` patched to interrupt), and a live in-flight fused `await` (no mocking — `_thread.interrupt_main()` fired only once the server confirms the request arrived, handler then released so the executor teardown has a real worker to wait out; post-[#848](https://github.com/aallan/vera/issues/848) the progress print precedes the `async(...)`, so program order makes its stdout assertion deterministic), raise `KeyboardInterrupt` from inside the blocking call, and assert the program exits with `ExecuteResult.exit_code == 130` (pre-interrupt stdout preserved) instead of a raw Python traceback escaping wasmtime's trampoline | | `test_serve.py` | 8 | 189 | #305 `vera serve` driver end-to-end: GET/POST echo round-trips (method/path/headers/body cross the host↔guest boundary via `build_request_adt` / `decode_response_adt`), handler status propagation, runtime contract violation → 500 with `trap_kind` JSON, `State` isolation across requests (instance-per-request pinned), and clean `make_server` validation errors (missing / wrong-signature `handle`), and an eager-GC round-trip pinning the Request builder's shadow-rooting; all on ephemeral ports | -| `test_wasi_target.py` | 261 | 2,142 | #237 WASI Preview 2 target (spec chapter 13): component emission validated live against the real wasmtime host — parse (`Component(engine, wat)`), instantiate (`Linker.add_wasip2()` + `WasiConfig`), and execute (stdout/stderr capture, env, argv incl. a 500-arg GC-pressure stress and a >64 KiB arena-cap trap, preopen file round-trips + errno mapping, stdin incl. UTF-8 multibyte, clocks, random bounds, exit, contract-violation text on WASI stderr, overflow); the family gate (clean diagnostic naming unsupported families, never a silent fallback); the core-emission pin (default `--target wasm` WAT untouched); `cmd_compile`/`cmd_run --target wasi-p2` CLI integration (binary component artifact, `--wat` component text, JSON envelopes, trap-kind classification through the component boundary, exit-code 0/1 degradation, `--fn` rejection); the `execute_wasi_p2` host runner (env passthrough, argv, stderr capture, String-main `wasi:cli/run` fallback); the **dual-target conformance differential** (all 154 run-level conformance programs under both targets, byte-identical stdout/stderr required; nondeterministic-op and family-gated programs skip loudly); a stock-`wasmtime`-CLI smoke test (skips when the CLI is not installed); and the Stage-D **server world** (`world="server"`): incoming-handler emission pins (adapter lift, 32-slot dispatch table, @0.2.0 version pin, no `wasi:cli/run`), #305 handler validation + server family-gate diagnostics (rejected IO ops, non-String map instantiations, unsupported families), the cli-world pin (default emission carries no server machinery), Request/Response layout tripwires, and a stock-`wasmtime serve` smoke battery (host-vs-served differential over a method/path/header/body matrix incl. duplicate-header later-wins, in-guest map-op order parity, `IO.print` console routing, trap→500 with symbolized backtrace + violation text, graceful 500s for forbidden headers and out-of-range status, a 1 MiB GC-stress echo, and an eager-GC shadow-push mutation validation; skips when the CLI is not installed) | +| `test_wasi_target.py` | 263 | 2,142 | #237 WASI Preview 2 target (spec chapter 13): component emission validated live against the real wasmtime host — parse (`Component(engine, wat)`), instantiate (`Linker.add_wasip2()` + `WasiConfig`), and execute (stdout/stderr capture, env, argv incl. a 500-arg GC-pressure stress and a >64 KiB arena-cap trap, preopen file round-trips + errno mapping, stdin incl. UTF-8 multibyte, clocks, random bounds, exit, contract-violation text on WASI stderr, overflow); the family gate (clean diagnostic naming unsupported families, never a silent fallback); the core-emission pin (default `--target wasm` WAT untouched); `cmd_compile`/`cmd_run --target wasi-p2` CLI integration (binary component artifact, `--wat` component text, JSON envelopes, trap-kind classification through the component boundary, exit-code 0/1 degradation, `--fn` rejection); the `execute_wasi_p2` host runner (env passthrough, argv, stderr capture, String-main `wasi:cli/run` fallback); the **dual-target conformance differential** (all 156 run-level conformance programs under both targets, byte-identical stdout/stderr required; nondeterministic-op and family-gated programs skip loudly); a stock-`wasmtime`-CLI smoke test (skips when the CLI is not installed); and the Stage-D **server world** (`world="server"`): incoming-handler emission pins (adapter lift, 32-slot dispatch table, @0.2.0 version pin, no `wasi:cli/run`), #305 handler validation + server family-gate diagnostics (rejected IO ops, non-String map instantiations, unsupported families), the cli-world pin (default emission carries no server machinery), Request/Response layout tripwires, and a stock-`wasmtime serve` smoke battery (host-vs-served differential over a method/path/header/body matrix incl. duplicate-header later-wins, in-guest map-op order parity, `IO.print` console routing, trap→500 with symbolized backtrace + violation text, graceful 500s for forbidden headers and out-of-range status, a 1 MiB GC-stress echo, and an eager-GC shadow-push mutation validation; skips when the CLI is not installed) | ## Conformance Suite -The conformance suite is a collection of 214 small, focused programs in `tests/conformance/` that systematically validate every language feature against the spec. Most programs are self-contained; the module-focused Chapter 8 cases use `import` statements where needed, and `ch07_cross_module_contracts.vera` still depends on `ch07_cross_module_contracts_lib.vera`. Each program tests one feature or a small group of related features. +The conformance suite is a collection of 216 small, focused programs in `tests/conformance/` that systematically validate every language feature against the spec. Most programs are self-contained; the module-focused Chapter 8 cases use `import` statements where needed, and `ch07_cross_module_contracts.vera` still depends on `ch07_cross_module_contracts_lib.vera`. Each program tests one feature or a small group of related features. Simon Willison [argues](https://simonwillison.net/tags/conformance-suites/) that conformance suites are a "huge unlock" for language projects — they transform development from trust-based to verification-based. The conformance suite serves as the definitive specification artifact that any implementation (or agent) can validate against. @@ -247,7 +249,7 @@ Each conformance program declares the deepest pipeline stage it must pass: | `parse` | Source text is syntactically valid | 0 | | `check` | Parses and type-checks cleanly | 39 | | `verify` | Type-checks and all contracts verified by Z3 | 15 | -| `run` | Compiles to WASM and executes correctly | 160 | +| `run` | Compiles to WASM and executes correctly | 162 | Almost all programs are at the `run` level — they compile and execute, producing correct results. Thirty-nine programs (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch03_typed_holes`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_cross_module_contracts_lib`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch07_bare_effect_op_rejected`, `ch08_circular_import`, `ch08_cross_module_generic_lib`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_transitive_module_import_base`, `ch08_visibility_private`, `ch08_xmod_widen_lib`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_eq_non_derivable_rejected`, `ch09_http`, `ch09_inference`, `ch09_ord_adt_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) are at the `check` level. Thirty-two of them — `ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch07_bare_effect_op_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, and `ch02_alias_cycle_rejected` — are **negative tests** that assert a specific diagnostic (E206, E135, E183, E201, E127, E153, E153, E153, E130, E130, E174, E182, E217, E011, E154, E154, E154, E154, E154, E154, E150, E152, E151, E242, E243, E207, E208, E208, E209, E128, E336, and E132 respectively) via the manifest's `expected_error` field; `ch09_http` and `ch09_inference` are environment-gated (network / API key). Fifteen programs (`ch03_slot_let_chains`, `ch03_slot_noncommutative`, `ch04_nested_option_ctor`, `ch04_primitive_obligations`, `ch05_apply_fn_typing`, `ch06_adt_sort_disambiguation`, `ch07_cross_module_contracts`, `ch07_io_read_char`, `ch07_io_sleep`, `ch07_random_effect`, `ch08_state_alias_module_table_lib`, `ch08_state_alias_per_module_lib`, `ch08_transitive_module_import_mid`, `ch09_http_server`, `ch09_math_builtins`) are at the `verify` level, using Z3-provable contracts — a library module is pinned at the deepest level it reaches, so the two per-module alias-table libraries are verified rather than only checked. @@ -368,7 +370,7 @@ tests/conformance/ ├── ch01_int_literals.vera # Chapter 1: Integer literals ├── ch01_float_literals.vera # Chapter 1: Float64 literals ├── ch01_string_escapes.vera # Chapter 1: String escape sequences -├── ... # 214 programs total, organized by spec chapter +├── ... # 216 programs total, organized by spec chapter ├── ch07_state_handler.vera # Chapter 7: State effect handler ├── ch07_exn_handler.vera # Chapter 7: Exn effect handler ├── ch09_numeric_builtins.vera # Chapter 9: Numeric built-in functions @@ -851,9 +853,9 @@ Twenty-eight scripts in `scripts/` validate cross-cutting concerns beyond unit t | Script | What it validates | |--------|-------------------| -| `check_conformance.py` | All 214 conformance entries hold at their declared level (parse/check/verify/run) — positives pass; the negatives fail `check` with their `expected_error` E-code | +| `check_conformance.py` | All 216 conformance entries hold at their declared level (parse/check/verify/run) — positives pass; the negatives fail `check` with their `expected_error` E-code | | `check_examples.py` | All 42 `.vera` examples pass `vera check` + `vera verify` | -| `check_corpus_canonical.py` | All 262 corpus programs (recursive over `examples/` + `tests/conformance/`) are in canonical form under `vera fmt` | +| `check_corpus_canonical.py` | All 264 corpus programs (recursive over `examples/` + `tests/conformance/`) are in canonical form under `vera fmt` | | `check_examples_readme.py` | Every `vera run` command in examples/README.md references an existing file and exported function | | `check_spec_examples.py` | 189 parseable code blocks from spec chapters: parse, type-check, and verify | | `check_readme_examples.py` | All Vera code blocks in README.md parse correctly | @@ -959,9 +961,9 @@ The repository configures 35 hooks across two stages: 33 run at the commit stage | `ruff check .` | Lint Python with ruff (default `F` + `E` rules) | | `mypy vera/` | Type-check compiler in strict mode | | `pytest tests/ -q` | Run full test suite | -| `check_conformance.py` | All 214 conformance entries hold at their declared level — positives pass; negatives fail `check` with their `expected_error` E-code | +| `check_conformance.py` | All 216 conformance entries hold at their declared level — positives pass; negatives fail `check` with their `expected_error` E-code | | `check_examples.py` | All 42 examples pass `vera check` + `vera verify` | -| `check_corpus_canonical.py` | All 262 `examples/` + `tests/conformance/` programs (recursive) are in canonical form (`vera fmt`) | +| `check_corpus_canonical.py` | All 264 `examples/` + `tests/conformance/` programs (recursive) are in canonical form (`vera fmt`) | | `check_examples_readme.py` | `vera run` commands in `examples/README.md` reference existing files and exported functions | | `check_readme_examples.py` | README code blocks parse correctly | | `check_examples_doc.py` | EXAMPLES.md code blocks parse correctly | diff --git a/docs/SKILL.md b/docs/SKILL.md index 5b8b15e1..674610ad 100644 --- a/docs/SKILL.md +++ b/docs/SKILL.md @@ -2393,7 +2393,7 @@ public fn main(@Unit -> @Unit) ## Conformance Suite -The `tests/conformance/` directory contains 214 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. +The `tests/conformance/` directory contains 216 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. Each program is organized by spec chapter (`ch01_int_literals.vera`, `ch04_match_basic.vera`, `ch07_state_handler.vera`, etc.) and the `manifest.json` file maps features to programs. When you need to see how a specific construct works, check the conformance program before reading the spec. diff --git a/docs/index.html b/docs/index.html index debb0579..5b782861 100644 --- a/docs/index.html +++ b/docs/index.html @@ -699,7 +699,7 @@

This page is also a machine-readable specificati Vera is under active development

- A complete compiler with 164 built-in functions, ten algebraic effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge), contract-driven testing via Z3, a language server with agent-facing proof deltas, and a 14-chapter specification. A 214-program conformance suite and 42 worked examples are validated against the spec on every pull request. All of it is developed openly on GitHub and released under the MIT licence. + A complete compiler with 164 built-in functions, ten algebraic effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge), contract-driven testing via Z3, a language server with agent-facing proof deltas, and a 14-chapter specification. A 216-program conformance suite and 42 worked examples are validated against the spec on every pull request. All of it is developed openly on GitHub and released under the MIT licence.

diff --git a/docs/index.md b/docs/index.md index 1666cc5a..67c01a29 100644 --- a/docs/index.md +++ b/docs/index.md @@ -281,7 +281,7 @@ For other models: point them at [`SKILL.md`](https://veralang.dev/SKILL.md) via ## Status -Vera is under [active development](https://raw.githubusercontent.com/aallan/vera/main/ROADMAP.md). A complete compiler with 164 built-in functions, ten algebraic effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge), contract-driven testing via [Z3](https://www.microsoft.com/en-us/research/project/z3-3/), and a 14-chapter specification. A 214-program conformance suite and 42 worked examples are validated against the spec on every pull request. All of it is developed openly on [GitHub](https://github.com/aallan/vera) and released under the MIT licence. +Vera is under [active development](https://raw.githubusercontent.com/aallan/vera/main/ROADMAP.md). A complete compiler with 164 built-in functions, ten algebraic effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge), contract-driven testing via [Z3](https://www.microsoft.com/en-us/research/project/z3-3/), and a 14-chapter specification. A 216-program conformance suite and 42 worked examples are validated against the spec on every pull request. All of it is developed openly on [GitHub](https://github.com/aallan/vera) and released under the MIT licence. ## Links diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 4d3db3a8..f3db2b08 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -2399,7 +2399,7 @@ public fn main(@Unit -> @Unit) ## Conformance Suite -The `tests/conformance/` directory contains 214 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. +The `tests/conformance/` directory contains 216 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. Each program is organized by spec chapter (`ch01_int_literals.vera`, `ch04_match_basic.vera`, `ch07_state_handler.vera`, etc.) and the `manifest.json` file maps features to programs. When you need to see how a specific construct works, check the conformance program before reading the spec. @@ -2478,7 +2478,7 @@ Read `SKILL.md` for the full language reference. It covers syntax, slot referenc ### Conformance programs as reference -The conformance suite in `tests/conformance/` contains 214 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-two negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) instead must *fail* `check` with the E-code in their `expected_error` field. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. +The conformance suite in `tests/conformance/` contains 216 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-two negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) instead must *fail* `check` with the E-code in their `expected_error` field. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. ### Workflow @@ -2657,9 +2657,9 @@ Each stage is a module with a single public API function (`parse_file`, `transfo pytest tests/ -v # Run all tests (see TESTING.md) pytest tests/test_conformance.py -v # Conformance suite only mypy vera/ # Type-check the compiler -python scripts/check_conformance.py # All 214 conformance programs hold (positives pass; negatives fail with their E-code) +python scripts/check_conformance.py # All 216 conformance programs hold (positives pass; negatives fail with their E-code) python scripts/check_examples.py # All 42 examples must pass -python scripts/check_corpus_canonical.py # All 262 corpus programs in canonical form +python scripts/check_corpus_canonical.py # All 264 corpus programs in canonical form ``` Test helpers follow a pattern: `_check_ok(source)` / `_check_err(source, match)` / `_verify_ok(source)` / `_verify_err(source, match)`. See existing tests for examples. @@ -2668,7 +2668,7 @@ When implementing a new language feature, write the conformance program *first* ### Invariants -- All 214 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) must *fail* `check` with their `expected_error` E-code +- All 216 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) must *fail* `check` with their `expected_error` E-code - All 42 examples in `examples/` must pass `vera check` and `vera verify` - `mypy vera/` must be clean - `pytest tests/ -v` must pass @@ -3213,7 +3213,7 @@ None of this is Vera-specific, but it validates the design choices. The thesis i This is a real concern. LLMs are trained on trillions of tokens of Python, TypeScript, and JavaScript. A MojoBench study (NAACL 2025) found that even fine-tuned models achieved only 30–35% improvement over base models on Mojo code generation, illustrating the cold-start problem for new languages. -Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (214 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification. +Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (216 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification. ## How does Vera compare to Dafny / Lean / Koka / F*? @@ -3256,7 +3256,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 10,486 tests, including a 214-program conformance suite +- 10,532 tests, including a 216-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/docs/llms.txt b/docs/llms.txt index 14c6ab45..06ddf4c0 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -56,4 +56,4 @@ Current version: 0.1.11. The reference compiler is written in Python. Install th - [TESTING.md](https://raw.githubusercontent.com/aallan/vera/main/TESTING.md): Test suite architecture, coverage data, and test conventions. - [KNOWN_ISSUES.md](https://raw.githubusercontent.com/aallan/vera/main/KNOWN_ISSUES.md): Known bugs and limitations. - [CONTRIBUTING.md](https://raw.githubusercontent.com/aallan/vera/main/CONTRIBUTING.md): Contribution guidelines. -- [Conformance Suite](https://github.com/aallan/vera/tree/main/tests/conformance): 214 programs validating every language feature against the spec. +- [Conformance Suite](https://github.com/aallan/vera/tree/main/tests/conformance): 216 programs validating every language feature against the spec. diff --git a/spec/07-effects.md b/spec/07-effects.md index 6f560700..ffcc3538 100644 --- a/spec/07-effects.md +++ b/spec/07-effects.md @@ -82,6 +82,8 @@ effects(, State>) This means the function uses two independent state cells: one `Int` and one `String`. +The cells being independent, a form that names one names it by its type argument: `old(State)` and `new(State)` (§7.9.2) both read the `Int` cell whatever else the row declares, and their `State` counterparts the `String` one. Only a *bare* operation call, which names no type argument, falls back on the written order of the row (§7.3.2). + The same effect with the same type parameters MUST NOT appear twice (it would be redundant). ## 7.4 Performing Effects @@ -112,6 +114,8 @@ public fn hello(-> @Unit) Effect operations are resolved by the effect declared in the function's effect row. If `get` appears in a function with `effects(>)`, it refers to the `get` operation of `State`. +**Declarations first.** A bare name is resolved as an operation only when no *function declaration* of that name is in scope. A program declaring `fn get` owns every bare `get(...)` in that declaration's scope — including inside a `handle[State]` body, which is not an exception — and the resolution order below never runs for it. Operation names are not reserved, so this is the ordinary shadowing rule rather than a special case, and it is a property of the call site's scope alone. The qualified spelling is unaffected: `State.get(())` names the effect, so no declaration can shadow it, and it is how a program that declares `fn get` still reaches the cell. + **Resolution order.** More than one effect in scope may declare the same operation name — the built-in `State` and `Http` both declare `get`. A bare operation name binds to the **first** effect that declares it in this order: the innermost enclosing `handle[E]` (§7.5), then each enclosing handler outwards, then the function's declared effect row **in the order the row is written**, and finally the **registered** effects in registration order — the built-ins first, then any user `effect` declaration in source order. So `effects(, Http>)` binds a bare `get` to `State`, `effects(>)` binds it to `Http`, and a `handle[State]` around the call binds it to `State` whatever the row says. Every step of that list is an ordered sequence, so the binding is a property of the program text alone — never of the order an implementation happens to enumerate the row's members. **Bare operation calls and routing (`E217`).** A bare (unqualified) operation name is only well-formed when the compiler can route it to a concrete implementation. The built-in `State` and `Exn` operations (`get`, `put`, `throw`) are always routable — they are backed by intrinsic host cells — so they may be called bare, as in `increment` above. Every other operation — those of `IO`, `DB`, `Http`, `Inference`, `Random`, and any user-declared effect — is routable bare only inside a `handle[E]` block for its effect `E` (§7.5); outside such a block it has no bare route and MUST be called qualified as `E.op(...)`, the way `hello` calls `IO.print`. Calling one of these operations bare with no enclosing handler is a compile-time error (`E217`), reported by the checker so the backend never receives an operation it cannot lower. diff --git a/tests/conformance/ch07_op_name_user_shadow.vera b/tests/conformance/ch07_op_name_user_shadow.vera new file mode 100644 index 00000000..90666b7d --- /dev/null +++ b/tests/conformance/ch07_op_name_user_shadow.vera @@ -0,0 +1,130 @@ +-- Chapter 7, Section 7.4 — a bare call names a DECLARATION before an operation. +-- Tests: a user fn shadowing get/put inside a handled body, under same-family and +-- different-family handler nesting, beside the qualified spelling that still +-- reaches the cell. +-- +-- A bare op resolves only for a name no declaration occupies, and that rule holds +-- everywhere a declaration is in scope — a handled body is not an exception. The +-- qualified State.get / State.put name the effect, so no declaration can shadow +-- them: writing both spellings in one program is what makes the user's function +-- and the cell distinguishable, and every value below reads one of the two. +private fn get(@Nat -> @Nat) + requires(true) + ensures(@Nat.result == @Nat.0 + 1) + effects(pure) +{ + @Nat.0 + 1 +} + +private fn put(@Nat -> @Nat) + requires(true) + ensures(@Nat.result == @Nat.0 * 2) + effects(pure) +{ + @Nat.0 * 2 +} + +-- Both bare calls in the handled body are the USER's functions — get(3) is 4 and +-- put(3) is 6 — while the cell, reached through the qualified spelling, keeps the +-- 5 the handler seeded: 4 * 100 + 6 * 10 + 5 = 465. Neither 4 nor 6 can be the +-- cell's value, so a call routed to the intrinsic cannot produce this result. +private fn shadowed_body(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + handle[State](@Int = 5) { + get(@Unit) -> { resume(@Int.0) }, + put(@Int) -> { resume(()) } + } in { + nat_to_int(get(3)) * 100 + nat_to_int(put(3)) * 10 + State.get(()) + } +} + +-- Same-family nesting. The inner handler's `with` state override is clause scope, +-- and the get(3) written there is the user's function, so the cell it stores is 4 +-- rather than the 9 the qualified put wrote: the enclosing-context addressing +-- question (7.5.2) never arises for a call that reaches no cell at all. +private fn nested_same_family(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + handle[State](@Int = 1) { + get(@Unit) -> { resume(@Int.0) }, + put(@Int) -> { resume(()) } + } in { + handle[State](@Int = 2) { + get(@Unit) -> { resume(@Int.0) }, + put(@Int) -> { resume(()) } with @Int = nat_to_int(get(3)) + } in { + State.put(9); + State.get(()) + } + } +} + +-- Different-family nesting. The outer cell is Bool and the inner is Int, so the +-- widths differ: a user call lowered to the enclosing handler's getter would put an +-- i32 where an i64 belongs. Same 4 as above, from the same clause-scope override. +private fn nested_cross_family(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + handle[State](@Bool = false) { + get(@Unit) -> { resume(@Bool.0) }, + put(@Bool) -> { resume(()) } + } in { + handle[State](@Int = 2) { + get(@Unit) -> { resume(@Int.0) }, + put(@Int) -> { resume(()) } with @Int = nat_to_int(get(3)) + } in { + State.put(9); + State.get(()) + } + } +} + +-- The declared-row spelling of the same rule: the bare get is the user's function +-- and the qualified one reads the caller's cell. 4 * 10 + 6 = 46. +private fn shadowed_row(@Unit -> @Int) + requires(true) + ensures(true) + effects(>) +{ + nat_to_int(get(3)) * 10 + State.get(()) +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(@Int.result == 0) + effects(pure) +{ + let @Int = shadowed_body(()); + if @Int.0 != 465 then { + 1 + } else { + let @Int = nested_same_family(()); + if @Int.0 != 4 then { + 2 + } else { + let @Int = nested_cross_family(()); + if @Int.0 != 4 then { + 3 + } else { + let @Int = handle[State](@Int = 6) { + get(@Unit) -> { resume(@Int.0) }, + put(@Int) -> { resume(()) } + } in { + shadowed_row(()) + }; + if @Int.0 != 46 then { + 4 + } else { + 0 + } + } + } + } +} diff --git a/tests/conformance/ch07_state_new_family.vera b/tests/conformance/ch07_state_new_family.vera new file mode 100644 index 00000000..a98ebe08 --- /dev/null +++ b/tests/conformance/ch07_state_new_family.vera @@ -0,0 +1,82 @@ +-- Chapter 7, Section 7.3.3 — new(State) names its cell, like old(State). +-- Tests: new() and old() under a multi-State effect row, across two cells of the +-- same machine width and two of different widths, and through a type alias. +-- +-- A row may carry several instantiations of State, and each is an independent +-- cell. A bare get(()) names no family, so it means whichever cell the row binds +-- first; new(State) names one explicitly and must read that one, exactly as +-- old(State) already does. Every value below is seeded from a caller's handler +-- at something the other cell in the row is not holding, so a read of the wrong +-- cell cannot coincide with the right answer. +type Count = Nat; + +-- Int and Nat are both i64, so nothing about the widths distinguishes the two +-- cells: only the family key does. The Nat cell holds 9 and the Int cell 42. +private fn same_width(@Unit -> @Int) + requires(true) + ensures(new(State) == 9) + ensures(new(State) == 42) + effects(, State>) +{ + 7 +} + +-- Different widths: reading the Int cell for a Bool contract puts an i64 where the +-- comparison needs an i32, so this shape cannot even load when the key is wrong. +-- The Bool cell keeps its default and the Int cell holds 42. +private fn cross_width(@Unit -> @Int) + requires(true) + ensures(new(State) == false) + effects(, State>) +{ + 8 +} + +-- old() and new() of one family on the two sides of one clause: the function +-- writes neither cell, so the claim is that the Nat cell is unchanged — which is +-- only true if both sides read it. +private fn unchanged(@Unit -> @Int) + requires(true) + ensures(new(State) == old(State)) + effects(, State>) +{ + 6 +} + +-- The alias hop: State resolves to the Nat family, which is the key both +-- the import registry and the old() snapshot map use. +private fn through_alias(@Unit -> @Int) + requires(true) + ensures(new(State) == 9) + effects(, State>) +{ + 5 +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(@Int.result == 0) + effects(pure) +{ + handle[State](@Int = 42) { + get(@Unit) -> { resume(@Int.0) }, + put(@Int) -> { resume(()) } + } in { + handle[State](@Nat = 9) { + get(@Unit) -> { resume(@Nat.0) }, + put(@Nat) -> { resume(()) } + } in { + handle[State](@Bool = false) { + get(@Unit) -> { resume(@Bool.0) }, + put(@Bool) -> { resume(()) } + } in { + let @Int = same_width(()) * 1000 + cross_width(()) * 100 + unchanged(()) * 10 + through_alias(()); + if @Int.0 != 7865 then { + 1 + } else { + 0 + } + } + } + } +} diff --git a/tests/conformance/manifest.json b/tests/conformance/manifest.json index 9a1b0892..39b5ad20 100644 --- a/tests/conformance/manifest.json +++ b/tests/conformance/manifest.json @@ -1324,6 +1324,37 @@ "state_update" ] }, + { + "id": "ch07_op_name_user_shadow", + "file": "ch07_op_name_user_shadow.vera", + "chapter": 7, + "title": "A bare call names a declaration before an operation", + "level": "run", + "spec_ref": "Section 7.4", + "features": [ + "state_effect", + "handler", + "nested_handlers", + "qualified_op", + "get", + "put" + ] + }, + { + "id": "ch07_state_new_family", + "file": "ch07_state_new_family.vera", + "chapter": 7, + "title": "new(State) names its cell, like old(State)", + "level": "run", + "spec_ref": "Section 7.3.3", + "features": [ + "state_effect", + "new_state", + "old_state", + "type_alias", + "handler" + ] + }, { "id": "ch07_handler_registration_positions", "file": "ch07_handler_registration_positions.vera", diff --git a/tests/test_handler_op_ownership_1284.py b/tests/test_handler_op_ownership_1284.py new file mode 100644 index 00000000..439b431c --- /dev/null +++ b/tests/test_handler_op_ownership_1284.py @@ -0,0 +1,398 @@ +"""#1284: whose declaration a bare ``get``/``put`` call site denotes. + +The checker answers user-fn-first: :meth:`_check_call_with_args` looks the +name up as a function *before* it looks it up as an effect operation, so a +program declaring ``fn get`` has every bare ``get(...)`` in it denote that +declaration — provably, since an arity or argument-type error at such a call +site reports against the USER's signature (E201/E202), never the op's. + +Codegen used to answer that question twice more, and differently. The +declared-effect-row registration in ``vera/codegen/functions.py`` guarded its +intrinsic mapping on the function table; the handler-expression installation +in ``vera/wasm/calls_handlers.py`` overwrote ``get``/``put`` unconditionally. +From check-green source that produced, depending on the nesting shape, a +silently wrong value, a module WASM validation rejects, or a spurious +``[E602]`` skip naming a State operation the user never wrote. + +Every expected value below is derived from the CHECKER's story — the user's +function was called, so its result is the answer — never from what codegen +happened to emit. The ``pre_fix`` note on each case records what codegen +actually produced before the fix, so a case that stops distinguishing the two +answers fails loudly rather than passing vacuously. + +The controls matter as much as the cases: a program that does NOT shadow the +name must still route its bare ops to the host intrinsics, which is what the +whole conformance handler corpus asserts in bulk and what +``test_unshadowed_*`` asserts here in the small. +""" + +from __future__ import annotations + +import pytest + +from tests.checker_helpers import _check_ok, _errors +from tests.codegen_helpers import _compile, _run +from tests.verifier_helpers import _verify_ok + + +# The user's own `get`: takes a @Nat, returns its successor. Chosen so its +# answer can never coincide with a state cell's — every case below seeds the +# cell with a value the user function cannot produce from the argument used. +_USER_GET = """ +private fn get(@Nat -> @Nat) + requires(true) + ensures(true) + effects(pure) +{ + @Nat.0 + 1 +} +""" + +_USER_PUT = """ +private fn put(@Nat -> @Nat) + requires(true) + ensures(true) + effects(pure) +{ + @Nat.0 * 2 +} +""" + + +# --- the resolution rule, stated at the checker ------------------------ + +def test_checker_resolves_bare_get_to_the_user_declaration() -> None: + """E201 against the USER signature — the derivation this fix threads. + + ``get`` under a ``handle[State]`` whose own ``get`` clause takes + ``@Unit``: passing two arguments reports the *user* function's arity + (1), so the checker has resolved the call to the declaration, not to + the op. This is the fact the two codegen sites must agree with. + """ + errs = _errors(_USER_GET + """ +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + handle[State](@Int = 5) { + get(@Unit) -> { resume(@Int.0) }, + put(@Int) -> { resume(()) } + } in { + nat_to_int(get(3, 4)) + } +} +""") + assert len(errs) == 1, [d.description for d in errs] + assert errs[0].error_code == "E201" + # The user's arity, not the op's — `get(@Unit)` is arity 1 too, so the + # message body is what distinguishes them. + assert "expects 1 argument" in errs[0].description + + +# --- shape 1: handled body, scalar result (silently wrong value) ------- + +_HANDLED_BODY = _USER_GET + """ +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + handle[State](@Int = 5) { + get(@Unit) -> { resume(@Int.0) }, + put(@Int) -> { resume(()) } + } in { + nat_to_int(get(3)) + } +} +""" + + +def test_handled_body_user_get_returns_the_user_answer() -> None: + """pre_fix: 5 (the cell). The checker's answer is get(3) = 4.""" + _check_ok(_HANDLED_BODY) + _verify_ok(_HANDLED_BODY) + assert _run(_HANDLED_BODY) == 4 + + +def test_handled_body_user_get_emits_the_user_call() -> None: + """The dispatch target, not just the value: `call $get`, no intrinsic.""" + result = _compile(_HANDLED_BODY) + assert result.wat is not None + assert "call $get" in result.wat + assert "call $vera.state_get_Int" not in result.wat + + +# --- shape 2: handled body, Bool result (module fails validation) ------ + +_HANDLED_BODY_BOOL = """ +private fn get(@Nat -> @Bool) + requires(true) + ensures(true) + effects(pure) +{ + @Nat.0 > 0 +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + handle[State](@Int = 5) { + get(@Unit) -> { resume(@Int.0) }, + put(@Int) -> { resume(()) } + } in { + if get(3) then { + 1 + } else { + 0 + } + } +} +""" + + +def test_handled_body_user_get_bool_result_loads() -> None: + """pre_fix: `state_get_Int`'s i64 into the `if`'s i32 — the module was + rejected at load with wasmtime's raw `type mismatch: expected i32, + found i64`. get(3) is 3 > 0, so the checker's answer is 1.""" + _check_ok(_HANDLED_BODY_BOOL) + assert _run(_HANDLED_BODY_BOOL) == 1 + + +# --- shape 3: same-family nesting (spurious [E602] skip) --------------- + +# The user's `get` sits in the INNER handler's put clause, whose +# declaration-time op registry is the OUTER handled body's — where the +# unconditional overwrite had installed the intrinsic. Because both +# handlers are State, the #1233 unaddressable-cell gate then refused +# `main` outright, naming a State operation the user never wrote. +_NEST_SAME_FAMILY = _USER_GET + """ +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + handle[State](@Int = 1) { + get(@Unit) -> { resume(@Int.0) }, + put(@Int) -> { resume(()) } + } in { + handle[State](@Int = 2) { + get(@Unit) -> { resume(@Int.0) }, + put(@Int) -> { resume(()) } with @Int = nat_to_int(get(3)) + } in { + put(9); + State.get(()) + } + } +} +""" + + +def test_same_family_nesting_compiles_and_calls_the_user_fn() -> None: + """pre_fix: [E602], `main` dropped from the output entirely. + + The inner put clause overrides the stored value with the USER `get`'s + answer — get(3) = 4 — so the cell holds 4, not the 9 that was put. + """ + _check_ok(_NEST_SAME_FAMILY) + result = _compile(_NEST_SAME_FAMILY) + assert "main" in result.exports + assert not [d for d in result.diagnostics if d.error_code == "E602"], ( + [d.description for d in result.diagnostics] + ) + assert "call $get" in result.wat + assert _run(_NEST_SAME_FAMILY) == 4 + + +# --- shape 4: different-family nesting (module fails validation) ------- + +_NEST_DIFF_FAMILY = _USER_GET + """ +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + handle[State](@Bool = false) { + get(@Unit) -> { resume(@Bool.0) }, + put(@Bool) -> { resume(()) } + } in { + handle[State](@Int = 2) { + get(@Unit) -> { resume(@Int.0) }, + put(@Int) -> { resume(()) } with @Int = nat_to_int(get(3)) + } in { + put(9); + State.get(()) + } + } +} +""" + + +def test_different_family_nesting_loads_and_calls_the_user_fn() -> None: + """pre_fix: `call $vera.state_get_Bool` emitted for the user's `get`, + and the module was rejected at load (`expected i64, found i32`).""" + _check_ok(_NEST_DIFF_FAMILY) + result = _compile(_NEST_DIFF_FAMILY) + assert result.wat is not None + assert "call $get" in result.wat + assert _run(_NEST_DIFF_FAMILY) == 4 + + +# --- the same rule for `put` ------------------------------------------ + +_USER_PUT_BODY = _USER_PUT + """ +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + handle[State](@Int = 5) { + get(@Unit) -> { resume(@Int.0) }, + put(@Int) -> { resume(()) } + } in { + nat_to_int(put(3)) + } +} +""" + + +def test_user_put_is_not_hijacked_by_the_handler() -> None: + """`put` is the void op; the user's returns a @Nat. pre_fix the call + lowered to `state_put_Int` and the value-position use had nothing on + the stack. The checker's answer is put(3) = 6.""" + _check_ok(_USER_PUT_BODY) + result = _compile(_USER_PUT_BODY) + assert result.wat is not None + assert "call $put" in result.wat + assert _run(_USER_PUT_BODY) == 6 + + +# --- new(State) still resolves when the op NAME is shadowed --------- + +_SHADOWED_NEW = _USER_GET + """ +private fn probe(@Unit -> @Int) + requires(true) + ensures(new(State) == 42) + effects(>) +{ + nat_to_int(get(3)) +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + handle[State](@Int = 42) { + get(@Unit) -> { resume(@Int.0) }, + put(@Int) -> { resume(()) } + } in { + probe(()) + } +} +""" + + +def test_new_state_resolves_with_a_shadowed_op_name() -> None: + """`new(State)` is a CONTRACT form keyed on the cell family, not a + call named `get`, so a user `fn get` must not take its getter away. + + Withholding `get` from the op registry without a family-keyed getter + would raise `new(State) has no 'get' effect op registered`; this + pins that the two changes compose. probe's own body calls the USER's + `get` (3 + 1 = 4) while its postcondition reads the cell (42). + """ + _check_ok(_SHADOWED_NEW) + assert _run(_SHADOWED_NEW) == 4 + + +# --- controls: an UNSHADOWED name still reaches the intrinsics -------- + +_UNSHADOWED = """ +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + handle[State](@Int = 5) { + get(@Unit) -> { resume(@Int.0) }, + put(@Int) -> { resume(()) } + } in { + put(7); + get(()) + } +} +""" + + +def test_unshadowed_bare_ops_still_route_to_the_intrinsics() -> None: + """The control the fix must not move: with no user declaration owning + the name, `get`/`put` are the handler's ops exactly as before.""" + result = _compile(_UNSHADOWED) + assert result.wat is not None + assert "call $vera.state_get_Int" in result.wat + assert "call $vera.state_put_Int" in result.wat + assert _run(_UNSHADOWED) == 7 + + +_UNSHADOWED_ROW = """ +private fn probe(@Unit -> @Int) + requires(true) + ensures(true) + effects(>) +{ + get(()) * 10 +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + handle[State](@Int = 6) { + get(@Unit) -> { resume(@Int.0) }, + put(@Int) -> { resume(()) } + } in { + probe(()) + } +} +""" + + +def test_unshadowed_declared_row_op_still_routes_to_the_intrinsic() -> None: + """The other injection site's control — the declared-effect-row path.""" + assert _run(_UNSHADOWED_ROW) == 60 + + +# --- the two codegen sites answer with the checker, on one derivation -- + +@pytest.mark.parametrize( + "source,shadowed,expected,pre_fix", + [ + (_HANDLED_BODY, "get", 4, "5 (the cell, silently)"), + (_HANDLED_BODY_BOOL, "get", 1, "module rejected at load"), + (_NEST_SAME_FAMILY, "get", 4, "[E602], main dropped"), + (_NEST_DIFF_FAMILY, "get", 4, "module rejected at load"), + (_USER_PUT_BODY, "put", 6, "nothing on the stack"), + ], +) +def test_checker_and_codegen_agree_on_every_shadowed_shape( + source: str, shadowed: str, expected: int, pre_fix: str, +) -> None: + """The cross-component differential: run both sides and compare. + + ``expected`` is read off the CHECKER's resolution — it resolved the + call to the user's declaration, so the value is that function's — and + the assertion is that the compiled program produces it. A unit test on + either side alone cannot see this: codegen was internally consistent + with itself the whole time, and the checker never learned what codegen + emitted. ``pre_fix`` records what each shape did instead, so a case + that stops distinguishing the two answers is visible in the table. + """ + _check_ok(source) + result = _compile(source) + assert "main" in result.exports, pre_fix + assert f"call ${shadowed}" in result.wat, pre_fix + assert _run(source) == expected, pre_fix diff --git a/tests/test_mono_effect_op_naming_1207.py b/tests/test_mono_effect_op_naming_1207.py index 28ee5587..c37abfcc 100644 --- a/tests/test_mono_effect_op_naming_1207.py +++ b/tests/test_mono_effect_op_naming_1207.py @@ -195,11 +195,16 @@ } """ -# A user function named `get` INSIDE a handler body: the operation wins, -# matching codegen's unconditional `_effect_ops` overwrite in -# `_translate_handle_state` (unlike the declared-row site below, where the -# function wins). Its `@Int` return is what makes the case discriminate — -# resolving to the function would name `pick$Int`, the cell names `pick$Nat`. +# A user function named `get` INSIDE a handler body: the FUNCTION wins, as +# it does at the declared-row site — the checker resolves a bare name to a +# declaration of it wherever one is in scope, handler body included, and +# both discovery and the rewrite now ask that one question at the call site +# (#1284). Its `@Int` return is what makes the case discriminate: resolving +# to the cell would name `pick$Nat`, the function names `pick$Int`. +# +# This case previously expected `pick$Nat`, pinning codegen's unconditional +# `_effect_ops` overwrite in `_translate_handle_state` as the oracle — a +# value the checker never agreed with, and the disagreement #1284 is. _USER_GET_UNDER_HANDLER = """ private fn get(@Unit -> @Int) requires(true) @@ -231,7 +236,7 @@ ("direct_arg", _DIRECT_ARG, "second$Nat", 9), ("exn_nested_in_state", _EXN_IN_STATE, "pick$Nat", 9), ("nested_distinct_state", _NESTED_DISTINCT_STATE, "pick$Int", 15), - ("user_get_under_handler", _USER_GET_UNDER_HANDLER, "pick$Nat", 9), + ("user_get_under_handler", _USER_GET_UNDER_HANDLER, "pick$Int", 9), ] # The BUILTIN sibling of the same value position: `get(())` as an diff --git a/tests/test_nat_narrowing_return_differential.py b/tests/test_nat_narrowing_return_differential.py index 6b74202b..3891fa4d 100644 --- a/tests/test_nat_narrowing_return_differential.py +++ b/tests/test_nat_narrowing_return_differential.py @@ -2737,14 +2737,22 @@ def test_deep_alias_chain_joins_the_base_family(self) -> None: } """ - def test_qualified_put_user_shadow_is_loud(self) -> None: - """A user fn named `put` in a DELEGATED context (handler in the - caller): the fn-level effect-op mapping is skipped by the shadow - carve-out, so the round-4 delegation dispatched the synthesized - bare call to the USER fn silently (checker semantics: the - builtin op). The delegation is now gated on the dispatcher - actually resolving the op — the unresolved case fails loudly at - module compile (the pre-round-4 behaviour).""" + def test_qualified_put_reaches_the_cell_past_a_user_shadow(self) -> None: + """A user fn named `put` alongside a QUALIFIED `State.put(5)`. + + The checker's semantics have always been the builtin op — the + qualifier names the effect, so no declaration can shadow it — and + this now lowers that way. It could not before #1284: the effect-op + registry was withheld whenever a user function owned the name, which + answered "whose name is this?" and "which cell does the op reach?" + with one table, so the qualified spelling lost its cell too and the + module failed to link (`unknown func: $vera.put`). The registry is + now complete and ownership is asked at the bare dispatch, so the two + spellings differ only where the language says they do. + + The bare `get(())` beside it is NOT shadowed and reads the same + cell, so the value is the one `State.put` stored. + """ with _resolved_pipeline(self._QUAL_USER_SHADOW) as ( program, arts, resolved, path): result = codegen_compile( @@ -2752,11 +2760,12 @@ def test_qualified_put_user_shadow_is_loud(self) -> None: resolved_modules=resolved, expr_semantic_types=arts.expr_semantic_types, ) - assert not result.ok - msgs = [d.description for d in result.diagnostics] - assert any( - "unknown func" in m and "$vera.put" in m for m in msgs - ), msgs + assert result.ok, [d.description for d in result.diagnostics] + assert "call $vera.state_put_Int" in result.wat + # The user's own `put` is still emitted and still callable — it + # is simply not what the qualified site denotes. + assert "(func $put " in result.wat + assert _run(self._QUAL_USER_SHADOW, "go", 7) == 5 class TestClauseClassCollisionBothDirections: diff --git a/tests/test_new_state_family_1285.py b/tests/test_new_state_family_1285.py new file mode 100644 index 00000000..08b68992 --- /dev/null +++ b/tests/test_new_state_family_1285.py @@ -0,0 +1,318 @@ +"""#1285: which cell ``new(State)`` reads under a multi-``State`` row. + +``old(State)`` has been family-keyed since #1205/#1209 — the snapshot map +and the read both go through ``_state_effect_family``, so +``old(State)`` reaches the Bool cell whatever else the row declares. +``new(State)`` instead read the name-keyed ``_effect_ops["get"]``, which +holds whichever family the row registered FIRST. Under a single-``State`` +row the two keyings coincide, which is why every existing program agreed; +under a multi-``State`` row they do not, and the two sides of one ``ensures`` +clause were reading different cells. + +The failure was not quiet. ``effects(, State>)`` with +``ensures(new(State) == true)`` is check-green and verify-green, emits +``state_get_Int``'s i64 into the Bool comparison's ``i32.eq``, and dies at +load with wasmtime's raw ``type mismatch``. But the type mismatch is the +symptom of a wrong cell, not the defect: where both cells share a width the +module loads and silently answers about the other one, which is what the +``Int``/``Nat`` case below pins. + +Each case seeds its target cell with a value the OTHER cell in the row +cannot be holding, so a read of the wrong cell cannot coincide with the +right answer. The postconditions are Tier 3 (E523 — ``new()`` is outside the +decidable fragment), so they are compiled to runtime checks: the program +trapping on its own ``ensures`` is what a wrong-cell read looks like here, +which makes each case an ensures-and-run soundness differential rather than +a value comparison alone. +""" + +from __future__ import annotations + +import pytest + +from tests.checker_helpers import _check_ok +from tests.codegen_helpers import _compile, _run +from tests.verifier_helpers import _verify_ok + + +# --- the issue's shape: Bool named second, read at the Bool cell ------- + +# The Bool cell is left at its default (false) and the Int cell is seeded +# with 42 by the caller's handler. Reading the Int cell for +# `new(State)` cannot produce `false`: 42 is neither 0 nor a valid +# i32 the `i32.eq` would accept, which is why this shape failed at load. +_BOOL_SECOND = """ +private fn probe(@Unit -> @Int) + requires(true) + ensures(new(State) == false) + effects(, State>) +{ + 7 +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(>) +{ + handle[State](@Int = 42) { + get(@Unit) -> { resume(@Int.0) }, + put(@Int) -> { resume(()) } + } in { + probe(()) + } +} +""" + + +def test_new_reads_the_family_the_contract_names_not_the_rows_first() -> None: + """pre_fix: `call $vera.state_get_Int` (i64) into the Bool `i32.eq`, + and `vera run` died at load with `type mismatch: expected i32, found + i64` — from source both `vera check` and `vera verify` accept.""" + _check_ok(_BOOL_SECOND) + _verify_ok(_BOOL_SECOND) + assert _run(_BOOL_SECOND) == 7 + + +def test_new_emits_the_named_familys_getter() -> None: + """The dispatch target itself, so a case that happened to agree on + values still fails when the wrong import is called.""" + result = _compile(_BOOL_SECOND) + assert "call $vera.state_get_Bool" in result.wat + + +# --- the same defect where BOTH cells are i64 (loads, wrong answer) ---- + +# `Int` and `Nat` are both i64, so nothing about the widths refuses this +# module: pre-fix it loaded, read the FIRST-registered family's cell, and +# either answered about the wrong cell or trapped on its own postcondition. +# The two cells hold 42 and 9 — neither is the other, and neither is a +# default — so the read cannot be right by coincidence. +_NAT_SECOND = """ +private fn probe(@Unit -> @Int) + requires(true) + ensures(new(State) == 9) + effects(, State>) +{ + 7 +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + handle[State](@Int = 42) { + get(@Unit) -> { resume(@Int.0) }, + put(@Int) -> { resume(()) } + } in { + handle[State](@Nat = 9) { + get(@Unit) -> { resume(@Nat.0) }, + put(@Nat) -> { resume(()) } + } in { + probe(()) + } + } +} +""" + + +def test_new_same_width_cells_read_the_named_one() -> None: + """The width-blind case: pre-fix this loaded and trapped on probe's own + ensures, having read the Int cell's 42 where the contract named the Nat + cell's 9. A postcondition the verifier discharged and the runtime + refutes is the soundness shape, not a codegen inconvenience.""" + _check_ok(_NAT_SECOND) + _verify_ok(_NAT_SECOND) + assert _run(_NAT_SECOND) == 7 + result = _compile(_NAT_SECOND) + assert "call $vera.state_get_Nat" in result.wat + + +# --- old() and new() on the two sides of one clause ------------------- + +_OLD_AND_NEW = """ +private fn probe(@Unit -> @Int) + requires(true) + ensures(new(State) == old(State)) + effects(, State>) +{ + 7 +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + handle[State](@Int = 42) { + get(@Unit) -> { resume(@Int.0) }, + put(@Int) -> { resume(()) } + } in { + handle[State](@Nat = 9) { + get(@Unit) -> { resume(@Nat.0) }, + put(@Nat) -> { resume(()) } + } in { + probe(()) + } + } +} +""" + + +def test_old_and_new_of_one_family_read_one_cell() -> None: + """The clause that names the SAME family twice: `old` was already + family-keyed, so pre-fix the two sides read different cells and the + unchanged-cell claim was refuted at runtime (9 vs 42).""" + _check_ok(_OLD_AND_NEW) + _verify_ok(_OLD_AND_NEW) + assert _run(_OLD_AND_NEW) == 7 + + +# --- controls: the single-State row is unmoved ------------------------ + +_SINGLE = """ +private fn probe(@Unit -> @Int) + requires(true) + ensures(new(State) == 42) + effects(>) +{ + 7 +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + handle[State](@Int = 42) { + get(@Unit) -> { resume(@Int.0) }, + put(@Int) -> { resume(()) } + } in { + probe(()) + } +} +""" + + +def test_single_state_new_is_unchanged() -> None: + """Under one State the name-keyed and family-keyed lookups coincide; + this is the shape the whole existing corpus exercises.""" + _check_ok(_SINGLE) + _verify_ok(_SINGLE) + assert _run(_SINGLE) == 7 + result = _compile(_SINGLE) + assert "call $vera.state_get_Int" in result.wat + + +_SINGLE_ALIAS = """ +type Count = Nat; + +private fn probe(@Unit -> @Int) + requires(true) + ensures(new(State) == 9) + effects(>) +{ + 7 +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + handle[State](@Count = 9) { + get(@Unit) -> { resume(@Count.0) }, + put(@Count) -> { resume(()) } + } in { + probe(()) + } +} +""" + + +def test_new_through_an_alias_resolves_the_same_family_old_does() -> None: + """`_state_effect_family` resolves `State` to the `Nat` family + (#1205), which is the key both the import registry and the snapshot map + use — so routing `new()` through it must not lose the alias hop.""" + _check_ok(_SINGLE_ALIAS) + _verify_ok(_SINGLE_ALIAS) + assert _run(_SINGLE_ALIAS) == 7 + + +# --- the runtime check is real, not vacuous --------------------------- + +_REFUTED = """ +private fn probe(@Unit -> @Int) + requires(true) + ensures(new(State) == 8) + effects(, State>) +{ + 7 +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + handle[State](@Int = 42) { + get(@Unit) -> { resume(@Int.0) }, + put(@Int) -> { resume(()) } + } in { + handle[State](@Nat = 9) { + get(@Unit) -> { resume(@Nat.0) }, + put(@Nat) -> { resume(()) } + } in { + probe(()) + } + } +} +""" + + +_UNDECLARED_FAMILY = """ +public fn probe(@Unit -> @Int) + requires(true) + ensures({form}(State) == false) + effects(>) +{{ + 7 +}} +""" + + +@pytest.mark.parametrize("form", ["new", "old"]) +def test_a_family_the_row_does_not_declare_is_loud_on_both_sides( + form: str, +) -> None: + """`new()` and `old()` fail the SAME way on a family with no cell. + + The checker accepts a contract naming a `State` the effect row does + not declare (tracked as #1298), so codegen is where it lands. `old()` + has always reported E699 here — there is no snapshot local. `new()` + could not: the name-keyed lookup found the row's OTHER getter and read + the wrong cell, which is #1285 with the two questions maximally far + apart. Pinning both together is what keeps the family keying honest: + a `new()` that fell back to any getter would pass this file's other + cases and fail only here. + """ + source = _UNDECLARED_FAMILY.format(form=form) + _check_ok(source) + result = _compile(source) + codes = [d.error_code for d in result.diagnostics] + assert "E699" in codes, [d.description for d in result.diagnostics] + + +def test_a_false_postcondition_still_traps() -> None: + """The instrument check. Every case above asserts a program RUNS, which + proves nothing unless a wrong `new()` would have been caught — so the + same shape with the cell's value off by one must trap on its Tier 3 + postcondition. Without this, a `new()` that read nothing at all would + pass the whole file. + """ + from vera.codegen.api import WasmTrapError + + _check_ok(_REFUTED) + with pytest.raises(WasmTrapError): + _run(_REFUTED) diff --git a/vera/README.md b/vera/README.md index 9456b797..23f2eaff 100644 --- a/vera/README.md +++ b/vera/README.md @@ -79,10 +79,10 @@ execute(compile_result, ...) # → run WASM via wasmtime | `types.py` | 814 | Type check | Semantic type representation | `Type`, `is_subtype()` | | `prelude.py` | 1,004 | Type check | Standard prelude — built-in ADT and combinator injection | `inject_prelude()`, `overridable_builtin_names()` | | `naming.py` | 789 | Type check | The ONE slot / slot-reference-key / State-Exn-family renderer (#1208, #1209) — the checker's rendering, as a total pure function over an `AliasEnv`, consumed by the checker, the monomorphizer, the verifier, the SMT layer, codegen, the tester, the LSP, and `vera check --explain-slots`. Also the ONE refinement-binder derivation, from the type expression for codegen's runtime guard (`refinement_binder_parts`) and from the predicate's own reference for the verifier and SMT layers (`predicate_binder_key`, #1226), both rendering through `slot_name`; and each consumer is handed the env of the module that DECLARED what it is rendering | `slot_name()`, `slot_ref_key()`, `family_name()`, `resolve_type_expr()`, `AliasEnv` | -| `slots.py` | 339 | Type check | Presentation over `naming.py`: slot resolution tables and their text/JSON rendering, plus the two scope walks the tables need (`forall` narrowing, `where`-helper nesting). The two walks here that are NOT naming say so in their docstrings — the alias-opaque syntactic spelling for WASM representation questions, and the last-resort name for a State/Exn cell family that resolves to none | `slot_table()`, `format_slot_table()`, `fn_slot_scope()`, `fn_scopes()`, `type_expr_slot_name()`, `family_fallback_name()` | +| `slots.py` | 396 | Type check | Presentation over `naming.py`: slot resolution tables and their text/JSON rendering, plus the two scope walks the tables need (`forall` narrowing, `where`-helper nesting). The walks here that are NOT naming say so in their docstrings — the alias-opaque syntactic spelling for WASM representation questions, the last-resort name for a State/Exn cell family that resolves to none, and the bare-call ownership predicate the checker, codegen, and mono discovery all resolve a `get`/`put` call site through | `slot_table()`, `format_slot_table()`, `fn_slot_scope()`, `fn_scopes()`, `type_expr_slot_name()`, `family_fallback_name()`, `bare_call_denotes_user_fn()` | | `environment.py` | 2,424 | Type check | Type environment, scope stacks, ability registry, all built-in registrations | `TypeEnv`, `AbilityInfo` | | `checker/` | 6,684 | Type check | Two-pass type checker (mixin package) | `typecheck()` | -| ` core.py` | 1,032 | | TypeChecker class, orchestration, contracts, constraint validation | | +| ` core.py` | 1,153 | | TypeChecker class, orchestration, contracts, constraint validation | | | ` resolution.py` | 486 | | AST TypeExpr → semantic Type, inference | | | ` modules.py` | 285 | | Cross-module registration (C7b/C7c), plus the per-module body check that makes a module's diagnostics independent of which file `vera check` was given (#1244) | | | ` registration.py` | 863 | | Pass 1 forward declarations, ability registration | | @@ -96,7 +96,7 @@ execute(compile_result, ...) # → run WASM via wasmtime | `smt.py` | 3,141 | Verify | Z3 translation layer; reads each callee's contract in the module that declared it (`_callee_contract_scope`), swapping the naming env its slots render against and the registry its bare-name calls resolve in as one `CalleeScope` (#1208, #1225) | `SmtContext`, `SlotEnv`, `CalleeScope` | | `verifier.py` | 9,177 | Verify | Contract verification; owns the per-module registries every rendering goes through — an imported callee's contract and an imported generic's clone are named, resolved, and quoted in the module that **declared** them (#1208, #1220, #1225) | `verify()` | | `wasm/` | 26,604 | Compile | WASM translation layer (package) | `WasmContext`, `WasmSlotEnv`, `StringPool` | -| ` ├ context.py` | 1,118 | | Composed WasmContext, expression dispatcher, block translation | | +| ` ├ context.py` | 1,265 | | Composed WasmContext, expression dispatcher, block translation | | | ` ├ helpers.py` | 641 | | WasmSlotEnv, StateClauseEntry, StringPool, type mapping, array element helpers | | | ` ├ inference.py` | 2,518 | | Type inference, slot/type utilities, operator tables | | | ` ├ operators.py` | 2,778 | | Binary/unary operators, if, quantifiers, assert/assume, old/new | | @@ -723,7 +723,7 @@ The WASM type inference system (`inference.py`) must also handle all expression **The environment is the other half of the contract, and getting it wrong fails just as silently.** An `AliasEnv` is module-scoped (spec §8.4.1), so every consumer renders against the env of the module that **declared** the enclosing function, narrowed by that function's `forall` variables (`slots.fn_slot_scope` — they shadow same-named module aliases): codegen through `_module_alias_scope`, the monomorphizer against each clone's origin module, the verifier from its own per-module registration, so an imported callee's contract is rendered in *its* namespace rather than the importer's. Rendering against a neighbouring module's namespace is the same failure as rendering with a different renderer. -Two derivations stay behind in `slots.py`, and both are about a type's **representation** rather than its name: `type_expr_slot_name` (the alias-opaque spelling the WASM width/erasure walks and the structural-`Eq` derivability oracle want) and `family_fallback_name` (the last-resort name for a family whose type expression resolves to none). `slots.py` is otherwise presentation — the tables `--explain-slots`, the LSP, and the verifier read — plus the shared `forall`-narrowing scope helper `fn_slot_scope`, which the tester and the monomorphizer import so their slot scopes narrow exactly the way the checker's do. +Two derivations stay behind in `slots.py`, and both are about a type's **representation** rather than its name: `type_expr_slot_name` (the alias-opaque spelling the WASM width/erasure walks and the structural-`Eq` derivability oracle want) and `family_fallback_name` (the last-resort name for a family whose type expression resolves to none). `slots.py` is otherwise presentation — the tables `--explain-slots`, the LSP, and the verifier read — plus two shared predicates the subsystems consult rather than restate: `fn_slot_scope`, the `forall`-narrowing scope helper the tester and the monomorphizer import so their slot scopes narrow exactly the way the checker's do, and `bare_call_denotes_user_fn` (#1284), which answers whether a bare `get`/`put` call site denotes a user declaration or the effect operation. The second exists for the same reason the first does: the checker resolves user-fn-first, and codegen's two op-registry sites and mono discovery each used to decide it themselves — the declared-effect row withheld the op when a function owned the name, the handler expression did not, and a `fn get` called under a `handle[State]` compiled to the host cell intrinsic the checker had never typed. The proof that the two sides agree is a differential, not a unit test: `tests/test_slot_naming_differential.py` instruments the checker's naming entry points, sweeps the whole `.vera` corpus plus a targeted battery, and requires zero divergence between the module's answer and a test-local statement of the rule. @@ -752,7 +752,7 @@ The `ERROR_CODES` dict in `errors.py` maps every code to a short description (15 ## Test Suite -Testing spans a **pytest suite** of 10,486 tests across 162 files — compiler-internals unit tests plus a **conformance suite** (214 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. +Testing spans a **pytest suite** of 10,532 tests across 164 files — compiler-internals unit tests plus a **conformance suite** (216 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. See **[TESTING.md](../TESTING.md)** for the comprehensive testing reference -- test file table, conformance suite details, compiler code coverage, language feature coverage, helper conventions, validation scripts, CI pipeline, and guidelines for adding tests. diff --git a/vera/checker/calls.py b/vera/checker/calls.py index 070dff2e..640b5155 100644 --- a/vera/checker/calls.py +++ b/vera/checker/calls.py @@ -8,6 +8,7 @@ from __future__ import annotations from vera import ast +from vera.slots import bare_call_denotes_user_fn from vera.checker.sql import ( count_placeholders, resolve_array_len, @@ -131,9 +132,22 @@ def _check_call_with_args(self, name: str, args: tuple[ast.Expr, ...], # last-wins registry, so a diamond of same-named helpers with # DIFFERENT signatures checks each parent against its OWN helper # (the flat lookup falsely E121'd a valid program). - fn_info = self._lookup_function_scoped(name) - if fn_info: - return self._check_fn_call_with_info(fn_info, args, node) + # + # #1284: user-fn-FIRST is not an implementation detail of this + # function, it is the language's bare-call ownership rule (spec + # §7.4: a bare op resolves only for a name no declaration occupies), + # and codegen has to lower every such call site the way this + # resolution read it. Asking through the shared predicate is what + # makes the checker's answer and codegen's the same rule over two + # tables rather than two rules that happened to agree: the two + # codegen legs used to disagree, and a `fn get` called under a + # `handle[State]` lowered to the host cell intrinsic — a silently + # wrong value, a module WASM validation rejected, or a spurious + # [E602] naming a State operation the user never wrote. + if bare_call_denotes_user_fn(name, self._user_fn_names): + fn_info = self._lookup_function_scoped(name) + if fn_info is not None: + return self._check_fn_call_with_info(fn_info, args, node) # Maybe it's an effect operation op_info = self.env.lookup_effect_op(name) diff --git a/vera/checker/core.py b/vera/checker/core.py index 41d9165b..7e6d3b3c 100644 --- a/vera/checker/core.py +++ b/vera/checker/core.py @@ -20,6 +20,7 @@ from __future__ import annotations +from collections.abc import Callable, Container from dataclasses import dataclass, replace from typing import TYPE_CHECKING @@ -59,6 +60,23 @@ from vera.checker.control import ControlFlowMixin +class _ScopedFnNames: + """Membership over the checker's LEXICAL function scope (#1284). + + A view rather than a set because the scope is a stack that changes as + checking descends: materialising it would freeze an answer the checker + itself would give differently one frame later. + """ + + __slots__ = ("_lookup",) + + def __init__(self, lookup: Callable[[str], object | None]) -> None: + self._lookup = lookup + + def __contains__(self, name: object) -> bool: + return isinstance(name, str) and self._lookup(name) is not None + + # ===================================================================== # Public API # ===================================================================== @@ -914,6 +932,19 @@ def _lookup_function_scoped(self, name: str) -> FunctionInfo | None: return top return self.env.lookup_function(name) + @property + def _user_fn_names(self) -> Container[str]: + """The checker's function table, as a membership view (#1284). + + What :func:`~vera.slots.bare_call_denotes_user_fn` consults on this + side: a name is the user's declaration here exactly when + :meth:`_lookup_function_scoped` resolves it, so the ownership + predicate reads the LEXICAL scope this checker resolves against — + not a flattened copy of it that could answer differently. Codegen + passes its own flat ``_fn_sigs`` mirror to the same predicate. + """ + return _ScopedFnNames(self._lookup_function_scoped) + def _type_expr_to_slot_name(self, te: ast.TypeExpr) -> str: """Extract the canonical slot name from a type expression used as a parameter binding. The head is the syntactic name — aliases are diff --git a/vera/codegen/functions.py b/vera/codegen/functions.py index e4b6310f..fa5c5f1c 100644 --- a/vera/codegen/functions.py +++ b/vera/codegen/functions.py @@ -310,12 +310,18 @@ def _compile_fn( effect_op_result_wt: dict[str, str | None] = {} effect_op_result_vera: dict[str, str | None] = {} effect_op_cells: dict[str, CellNames] = {} + # #1285: cell family -> getter import, for `new(State)`. + state_getters: dict[str, str] = {} # #1207: the op → Vera result-type table, from the ONE derivation # mono discovery also reads. Source-order-first-wins and the # unnameable-argument skip live in the shared builder, so the two - # consultors cannot drift; the `_fn_sigs` shadow guard below is - # this site's own (an op the row declares but a user function - # already owns is not injected here, and discovery mirrors that). + # consultors cannot drift. Shadowing is NOT filtered into these + # registries (#1284): they record which cell each op name reaches, + # and whether a given call site IS the op is asked at that site, + # through `_bare_call_denotes_op`. Withholding the entry here + # answered both questions with one table and made the second answer + # unavailable — `State.get(())` in a function that also declares + # `fn get` compiled to `call $vera.get` and failed to link. row_op_results = ( effect_op_result_names(decl.effect.effects) if isinstance(decl.effect, ast.EffectSet) else {} @@ -355,8 +361,17 @@ def _compile_fn( base=self._family_base_te(eff.type_args[0]), ) mangled = mangle_type_name(cell.family) - # Only map if no user-defined function shadows the op - if "get" not in self._fn_sigs and "get" not in effect_ops: + # #1285: the family-keyed getter, recorded for EVERY + # State in the row rather than only the first. A + # `new(State)` in a postcondition names its family + # the way `old(State)` does, so it reads this + # table; the name-keyed `effect_ops["get"]` beside it + # stays source-order-first-wins, which is the right + # rule for a bare `get(())` that names no family and + # the wrong one for a contract that does. + state_getters.setdefault( + cell.family, f"$vera.state_get_{mangled}") + if "get" not in effect_ops: effect_ops["get"] = ( f"$vera.state_get_{mangled}", False ) @@ -376,7 +391,7 @@ def _compile_fn( effect_op_result_vera["get"] = row_op_results.get( "get") effect_op_cells["get"] = cell - if "put" not in self._fn_sigs and "put" not in effect_ops: + if "put" not in effect_ops: effect_ops["put"] = ( f"$vera.state_put_{mangled}", True ) @@ -384,8 +399,7 @@ def _compile_fn( elif (isinstance(eff, ast.EffectRef) and eff.name == "Exn" and eff.type_args and len(eff.type_args) == 1): type_name = type_expr_slot_name(eff.type_args[0]) - if (type_name and "throw" not in self._fn_sigs - and "throw" not in effect_ops): + if type_name and "throw" not in effect_ops: # The tag name resolves like the State import # family (matching `_check_exn_type`, #1205/#1209). # IDENTITY names the tag; REPRESENTATION rides @@ -420,6 +434,7 @@ def _compile_fn( effect_op_result_wt=effect_op_result_wt, effect_op_result_vera=effect_op_result_vera, effect_op_cells=effect_op_cells, + state_getters=state_getters, ctor_layouts=ctor_layouts, adt_type_names=adt_type_names, generic_fn_info=getattr(self, "_generic_fn_info", None), diff --git a/vera/monomorphize.py b/vera/monomorphize.py index 69cb7cc3..b278028a 100644 --- a/vera/monomorphize.py +++ b/vera/monomorphize.py @@ -43,7 +43,11 @@ from vera import ast, naming from vera.naming import EMPTY_ALIAS_ENV, AliasEnv -from vera.slots import effect_op_result_names, fn_slot_scope +from vera.slots import ( + bare_call_denotes_user_fn, + effect_op_result_names, + fn_slot_scope, +) from vera.types import PRIMITIVES, REMOVED_ALIASES # Identifier tokens inside a rendered type name (`Map` → @@ -1347,15 +1351,18 @@ class MonoContext: loses the user-fn parameterized-return recovery, degrading to the prior (bare-name) behaviour rather than erroring. * ``fn_names`` — every function name this consumer's own table owns, used - for ONE decision: whether a declared effect row's ``get``/``put`` is an - effect operation here at all (#1207). Codegen keeps an op out of - ``_effect_ops`` when ``_fn_sigs`` already owns the name, so a program - declaring its own ``get`` resolves that call through the ordinary - function path; discovery has to make the same call or the two consultors - desync again in the shadowed direction. Optional (defaults empty): a - consumer that doesn't populate it treats no name as shadowed, which is - exactly the handler-expression rule (an op inside a ``handle`` body owns - its name unconditionally, matching ``_translate_handle_state``). + for ONE decision: whether a bare ``get``/``put`` CALL SITE is an effect + operation here at all (#1207, #1284). This is discovery's leg of + :func:`~vera.slots.bare_call_denotes_user_fn`, the predicate codegen + asks at its own dispatch through ``_bare_call_denotes_op`` and the + checker asks when it resolves the name; discovery has to make the same + call or the two consultors desync in the shadowed direction. It is + asked at the LOOKUP, never at the two registry installs — the declared + row and the handler expression both record their ops unfiltered, + exactly as codegen's two injection sites do. Optional (defaults + empty): a consumer that doesn't populate it treats no name as + shadowed, which is the answer for a program that declares no function + of an op's name — every program until one does. """ generic_decls: dict[str, ast.FnDecl] @@ -1607,14 +1614,16 @@ def _declared_type_names(self) -> frozenset[str]: return cached def _row_op_result_types(self, fn: ast.FnDecl) -> dict[str, str]: - """The effect-op result registry a function's declared row installs.""" + """The effect-op result registry a function's declared row installs. + + Unfiltered by shadowing (#1284), matching both the handler-expression + merge below and codegen's two injection sites: the table says what + each op name results in, and whether a given call site is that + operation is asked at the site, in ``_infer_vera_type_name``. + """ if not isinstance(fn.effect, ast.EffectSet): return {} - return { - op: name - for op, name in effect_op_result_names(fn.effect.effects).items() - if op not in self.ctx.fn_names - } + return dict(effect_op_result_names(fn.effect.effects)) def _collect_calls_in_node_scoped( self, @@ -2154,7 +2163,20 @@ def _infer_vera_type_name( # instantiation to the phantom default while the rewrite named the # cell's type: the clone discovery emitted dangled at the call the # rewrite emitted (loud E602, caller dropped with E620). - if isinstance(expr, ast.FnCall) and expr.name in self._op_result_types: + # #1284: and only when this call site IS the operation. The registry + # is populated unfiltered — it records what each op name results in, + # which is a fact about the row and the handler, not about the + # program's declarations — so the shadow question is asked here, at + # the site, exactly as codegen's `_infer_vera_type` asks it through + # `_bare_call_denotes_op`. Filtering at the two installation sites + # instead is what desynced them: the declared-row install filtered and + # the handler-expression merge did not, so a user `fn get` called + # under a `handle[State]` named the CELL's clone here and the + # user function's return type there. + if (isinstance(expr, ast.FnCall) + and not bare_call_denotes_user_fn( + expr.name, self.ctx.fn_names) + and expr.name in self._op_result_types): return self._op_result_types[expr.name] if isinstance(expr, ast.FnCall) and generic_decls: return self._infer_fncall_vera_type( diff --git a/vera/slots.py b/vera/slots.py index 8b311fe3..23c380b1 100644 --- a/vera/slots.py +++ b/vera/slots.py @@ -25,12 +25,13 @@ from __future__ import annotations from collections import defaultdict -from collections.abc import Iterable, Iterator +from collections.abc import Container, Iterable, Iterator from vera import ast, naming from vera.naming import AliasEnv __all__ = [ + "bare_call_denotes_user_fn", "effect_op_result_names", "family_fallback_name", "fn_scopes", @@ -42,6 +43,61 @@ ] +# ------------------------------------------------------------------ +# Bare-call ownership: whose declaration does this name denote? +# ------------------------------------------------------------------ + +def bare_call_denotes_user_fn( + name: str, user_fn_names: Container[str], +) -> bool: + """Whether a BARE call to *name* denotes a USER function (#1284). + + THE one answer to "is this ``get`` the user's declaration or the + handler's operation?", for every subsystem that has to know. The rule + is the type checker's, because the checker's answer is the one the + program was accepted under: :meth:`CallsMixin._check_call_with_args` + looks a bare name up as a *function* before it looks it up as an effect + operation, so a declaration named ``get`` owns every bare ``get(...)`` + in its scope — provably, since an arity or argument-type error at such + a call site reports against the USER's signature (E201/E202), never the + operation's. Spec §7.4 resolves a bare op only for a name no + declaration occupies. + + Consumers, each passing its OWN name table: + + * the checker's ``_check_call_with_args`` — the derivation, over its + LEXICAL function scope (#991); + * ``_translate_call`` in :mod:`vera.wasm.calls` — the bare dispatch, over + codegen's flat ``_fn_sigs`` mirror (``_known_fns``): a user-owned name + skips the clause-inline registry, the host-cell intrinsics, and the + #1233 addressability gate, and lowers as the ordinary call it is; + * the three bare-``FnCall`` inference sites in :mod:`vera.wasm.inference` + and :mod:`vera.wasm.context`, so a shadowed name is typed from the + function table rather than from the operation's result registry; + * :class:`~vera.monomorphize.Monomorphizer`'s discovery walk, over + ``MonoContext.fn_names``, so the clone it discovers for a ``get(())`` + in a value position is the clone the rewrite emits. + + What this deliberately does NOT gate is the op REGISTRIES themselves. + "Whose name is this?" and "which cell does the operation reach?" are two + questions, and answering the second with the first is what #1284 was: + the registries stay complete, so the QUALIFIED spelling + (``State.get(())``, which names the effect and so cannot be shadowed), + ``new(State)``, ``old(State)``, and the addressability gate keep + reaching their cell in a program that also declares ``fn get``. + + *user_fn_names* is a membership view, not a fixed set, so each consumer + supplies the table it actually resolves against — the checker a lexical + scope walk, codegen its flat signature keys. Those tables are not + identical (codegen keys a local ``where`` helper by its bare name; #1015 + hoists an imported one to ``parent$where$name``), which is the same + documented imprecision the verifier's ``fn_names`` mirrors — but the + RULE applied to them is now one function, so the sites cannot answer + differently about the table they share. + """ + return name in user_fn_names + + # ------------------------------------------------------------------ # Canonical slot-name construction (single source of truth) # ------------------------------------------------------------------ @@ -119,9 +175,10 @@ def effect_op_result_names( SOURCE ORDER, first wins, and an effect whose type argument has no slot name at all contributes nothing: both mirror the guards in ``codegen/functions.py``'s row loop, which a divergence here would - desync from. Callers that additionally shadow-guard on their own - function table (the declared-row site does; the handler site does not) - apply that filter to this result. + desync from. Shadowing is NOT filtered here and no caller filters it + on the way in (#1284): this table says what the operation results in, + and whether a given call site is the operation at all is + :func:`bare_call_denotes_user_fn`'s question, asked at the call site. """ out: dict[str, str] = {} for eff in effects: diff --git a/vera/wasm/calls.py b/vera/wasm/calls.py index 2b5c6ad1..0737f828 100644 --- a/vera/wasm/calls.py +++ b/vera/wasm/calls.py @@ -5,6 +5,7 @@ from vera import ast from vera.monomorphize import Monomorphizer, resolve_fn_type_alias from vera.skip import CodegenSkip +from vera.slots import bare_call_denotes_user_fn from vera.wasm.helpers import WasmSlotEnv @@ -29,18 +30,31 @@ class CallsMixin: """ def _translate_call( - self, call: ast.FnCall, env: WasmSlotEnv + self, call: ast.FnCall, env: WasmSlotEnv, + *, denotes_op: bool | None = None, ) -> list[str] | None: """Translate a function call to WASM call instruction. If the call name matches an effect operation (e.g. get/put for State), redirects to the corresponding host import. + + *denotes_op* overrides the bare-call ownership question (#1284) for + a call this dispatcher did not receive bare. ``None`` — every + ordinary ``ast.FnCall`` — asks :meth:`_bare_call_denotes_op`. + ``True`` is for the QUALIFIED spellings that delegate here by + synthesizing a bare node (``State.get``/``State.put``/``Exn.throw``, + below): the qualifier already named the effect, so no user + declaration can shadow it and the synthesized node must not be + re-asked as if the user had written the bare form. """ # Built-in intrinsics — only when no user-defined function # with the same name exists. User definitions take priority # so that e.g. a user-defined length(@List -> @Nat) is - # not mistakenly compiled as the array-length built-in. - if call.name not in self._known_fns: + # not mistakenly compiled as the array-length built-in. Same + # ownership rule the effect-op dispatch below applies, over the + # same table (#1284); spelled through the shared predicate so a + # change to the rule reaches both. + if not bare_call_denotes_user_fn(call.name, self._known_fns): if call.name == "array_length" and len(call.args) == 1: return self._translate_array_length(call.args[0], env) if call.name == "string_length" and len(call.args) == 1: @@ -432,19 +446,37 @@ def _translate_call( if call.name == "apply_fn" and len(call.args) >= 2: return self._translate_apply_fn(call, env) - # #1233: inside an inlined clause body, an outward-routed op of the - # SAME cell family cannot address the enclosing cell — the intrinsics - # only reach the innermost cell of a family. Refuse it here, before - # either dispatch below picks a route, so both the clause-inline and - # the bare-import path are covered by one gate (and so is the - # qualified `State.get`/`State.put` spelling, which delegates here). - self._reject_unaddressable_clause_op(call) + # #1284: whose declaration this call site names, asked ONCE and + # consumed by every op route below. The op registries are keyed by + # op NAME and say which cell that name reaches; they do NOT say + # whether this call is the operation at all, and reading them as if + # they did is the defect: a program declaring `fn get` had its + # ordinary calls lowered to the host cell intrinsic under any + # enclosing `handle[State]` — a silently wrong value, a module + # WASM validation rejected, or a spurious [E602] naming a State + # operation the source never contained. The checker resolved every + # one of those call sites to the user's declaration (E201/E202 + # report against the user's signature), so this is that answer. + if denotes_op is None: + denotes_op = self._bare_call_denotes_op(call.name) + + if denotes_op: + # #1233: inside an inlined clause body, an outward-routed op of + # the SAME cell family cannot address the enclosing cell — the + # intrinsics only reach the innermost cell of a family. Refuse + # it here, before either dispatch below picks a route, so both + # the clause-inline and the bare-import path are covered by one + # gate (and so is the qualified `State.get`/`State.put` + # spelling, which delegates here). Gated on ownership with the + # dispatch it guards: a user function's call reaches no cell, so + # asking whether it can address one refused compilable programs. + self._reject_unaddressable_clause_op(call) - # #976 option C: a get/put under a handle with registered clauses - # inlines the clause body at the call site (intrinsic-hybrid - # semantics) instead of the bare host-cell call below. - if call.name in self._state_clause_ops: - return self._translate_state_clause_op(call, env) + # #976 option C: a get/put under a handle with registered clauses + # inlines the clause body at the call site (intrinsic-hybrid + # semantics) instead of the bare host-cell call below. + if call.name in self._state_clause_ops: + return self._translate_state_clause_op(call, env) # Inside an inlined State clause, resume(v)'s value IS the op's # result at the original call site (single-shot, tail position — # enforced before inlining). resume(()) is a UnitLit: no value, @@ -461,7 +493,7 @@ def _translate_call( return self.translate_expr(call.args[0], env) # Check if this is an effect operation (e.g. get/put/throw) - if call.name in self._effect_ops: + if denotes_op and call.name in self._effect_ops: target_name, _is_void = self._effect_ops[call.name] instructions: list[str] = [] # #747: the effect-op-argument @Int -> @Nat narrowing is in @@ -672,16 +704,22 @@ def _translate_qualified_call( # `with` transform, stored a negative into a @Nat cell # silently, and emitted a Byte literal at i64 (round-4 # review). Delegation makes the two spellings identical by - # construction — but ONLY when the dispatcher will actually - # resolve the op: in a delegated fn where a user function - # shadows the name, `_compile_fn` skips the effect_ops - # mapping and the synthesized bare call would silently - # dispatch to the USER fn (round-5 review) — the unresolved - # case falls through to the legacy path's loud - # unknown-func failure instead. + # construction — guarded on the op resolving, so a row that + # registered no State cell still falls through to the legacy + # path's loud unknown-func failure. + # + # `denotes_op=True` (#1284): the qualifier NAMED the effect, so + # this call site is the operation whatever the program's + # declarations are called. Without it, the synthesized bare + # node would be re-asked the ownership question and, in a + # program that also declares `fn get`, silently dispatch to the + # user's function — the round-5 hazard, which the pre-#1284 + # `_fn_sigs` registry guard sidestepped only by making the + # registry incomplete (and so failing this spelling loudly at + # the declared-row site: `unknown func: $vera.get`). return self._translate_call( ast.FnCall(name=call.name, args=call.args, span=call.span), - env, + env, denotes_op=True, ) if (call.qualifier == "Exn" and call.name == "throw" and "throw" in self._effect_ops): @@ -692,9 +730,10 @@ def _translate_qualified_call( # compiled. Guarded on the op resolving, same as the State # twin — an unresolved `throw` falls through to the legacy path # below rather than synthesizing a bare call that would miss. + # `denotes_op=True` for the same reason as the State twin. return self._translate_call( ast.FnCall(name=call.name, args=call.args, span=call.span), - env, + env, denotes_op=True, ) instructions: list[str] = [] for arg in call.args: diff --git a/vera/wasm/calls_handlers.py b/vera/wasm/calls_handlers.py index 3747f165..bfc0a5b5 100644 --- a/vera/wasm/calls_handlers.py +++ b/vera/wasm/calls_handlers.py @@ -60,6 +60,7 @@ class CallsHandlersMixin: _effect_op_result_wt: dict[str, str | None] _effect_op_result_vera: dict[str, str | None] _effect_op_cells: dict[str, CellNames] + _state_getters: dict[str, str] _state_clause_ops: dict[str, StateClauseEntry] _state_clause_family_base: str | None _in_state_clause: bool @@ -1597,13 +1598,28 @@ def _translate_handle_state( # WAT type) to type a `get(())` array-literal element. #1207: from # the shared derivation, which mono discovery pushes for the same # `handle` expression — one table, so the clone discovery emits is - # the clone the rewrite below calls. No shadow guard here, matching - # the unconditional `_effect_ops` overwrite above: inside a handler - # body the op owns the name. + # the clone the rewrite below calls. + # + # No shadow guard at ANY of these four replacements, matching the + # declared-row site (#1284): they record which cell this handler's + # op names reach, which is true whatever the program's declarations + # are called, and every consumer that has to know whether a given + # bare call IS the op asks `_bare_call_denotes_op` at the call site. + # "Inside a handler body the op owns the name" is what this used to + # say, and it is not the language's rule — the checker resolves a + # bare `get` to a user declaration of that name anywhere it is in + # scope, handler body included. self._effect_op_result_vera = { **saved_result_vera, **effect_op_result_names([expr.effect]), } + # No `_state_getters` (#1285) replacement rides along here, and that + # is deliberate: the family-keyed getter table exists for + # `new(State)`, which is legal only in an `ensures` clause, and a + # contract is compiled outside the handled body this restores around + # — so a handler-scoped entry would be unreachable. The declared + # effect row, registered in `codegen/functions.py`, is the whole + # population. # #976 option C: register the clauses so each get/put CALL SITE in # the body inlines its clause body (intrinsic-hybrid semantics) # instead of the bare host-cell call. Start from an EMPTY registry: @@ -2441,7 +2457,9 @@ def _handle_exn_always_throws(self, expr: ast.HandleExpr) -> bool: # the declaration's own effect row — which is what makes the # rethrow shape divergent rather than a call to some `throw`. self._expr_always_throws( - clause.body, throw_installed="throw" in self._effect_ops, + clause.body, + throw_installed=(self._bare_call_denotes_op("throw") + and "throw" in self._effect_ops), ) for clause in expr.clauses ) @@ -2459,9 +2477,10 @@ def _expr_always_throws( ``throw_installed`` says whether a bare ``throw`` at this point IS the effect operation. Inside a ``handle[Exn]``'s handled body it always is — the injection at the translation site is unconditional — while - elsewhere the enclosing ``_effect_ops`` decides, so a program that - declares its own ``fn throw`` is read the same way the lowering reads - it. + elsewhere the enclosing ``_effect_ops`` decides, filtered by the + bare-call ownership predicate (#1284) exactly as the lowering filters + it, so a program that declares its own ``fn throw`` is read the same + way the lowering reads it. """ if isinstance(expr, ast.Block): return self._expr_always_throws( diff --git a/vera/wasm/context.py b/vera/wasm/context.py index 963fadc7..6c67a547 100644 --- a/vera/wasm/context.py +++ b/vera/wasm/context.py @@ -24,6 +24,7 @@ from vera import ast from vera.naming import EMPTY_ALIAS_ENV, AliasEnv from vera.skip import DERIVED_HELPER_DEPTH_CAP, CodegenSkip +from vera.slots import bare_call_denotes_user_fn if TYPE_CHECKING: from vera.codegen import ConstructorLayout @@ -89,6 +90,7 @@ def __init__( effect_op_result_wt: dict[str, str | None] | None = None, effect_op_result_vera: dict[str, str | None] | None = None, effect_op_cells: dict[str, CellNames] | None = None, + state_getters: dict[str, str] | None = None, ctor_layouts: dict[str, ConstructorLayout] | None = None, adt_type_names: set[str] | None = None, generic_fn_info: ( @@ -144,6 +146,21 @@ def __init__( # Only State get/put have entries; `throw` and user-effect ops reach # no host cell and are absent. self._effect_op_cells: dict[str, CellNames] = effect_op_cells or {} + # #1285: cell FAMILY -> that cell's `$vera.state_get_` import. + # The four registries above are keyed by op NAME, which is the right + # key for a call site (`get(())` names no family, so it means + # whichever cell the row or the enclosing handler binds) and the + # wrong key for a contract: `new(State)` names its family + # explicitly, exactly as `old(State)` does. Reading the + # name-keyed registry gave `new()` whichever family's getter was + # installed LAST, so under `effects(, State>)` a + # `new(State)` read `state_get_Int` — an i64 into the Bool + # comparison's `i32.eq`, check-green and verify-green, dead at load. + # Keyed and populated so `new()` resolves the way `old()` already + # did (`_state_effect_family` on both sides), and NOT filtered by + # bare-call ownership (#1284): a contract form names the effect, so + # a user `fn get` cannot shadow it. + self._state_getters: dict[str, str] = state_getters or {} # #976 option C: op_name -> :class:`StateClauseEntry` for the # innermost enclosing ``handle[State]``. When a get/put call site # has an entry here, the clause BODY is inlined at the site @@ -204,7 +221,12 @@ def __init__( ) # Constructor name → ADT name reverse mapping self._ctor_to_adt: dict[str, str] = ctor_to_adt or {} - # Known locally-defined function names (for cross-module guard rail) + # Known locally-defined function names (for cross-module guard rail, + # and — #1284 — codegen's leg of the bare-call ownership predicate: + # this is the flat mirror of `_fn_sigs` the checker's lexical scope + # is compared against). Empty when a context is built without one, + # which answers "no name is shadowed" — the pre-#1284 behaviour, and + # the safe default for a context that compiles no user declarations. self._known_fns: set[str] = known_fns or set() # Per-field ADT type-param indices for sparse constructors (e.g. Err → (1,)) self._ctor_adt_tp_indices: dict[str, tuple[int | None, ...]] = ( @@ -643,6 +665,21 @@ def get_old_state_local(self, type_name: str) -> int | None: """Get the local index holding the old() snapshot for a State type.""" return self._old_state_locals.get(type_name) + def _bare_call_denotes_op(self, name: str) -> bool: + """Is a BARE call to *name* here the effect operation? (#1284) + + Codegen's leg of :func:`~vera.slots.bare_call_denotes_user_fn`, over + the flat ``_fn_sigs`` mirror. Every bare-call site that consults an + op registry — the clause-inline dispatch, the host-cell intrinsics, + the #1233 addressability gate, and the three result-type inference + sites — asks this first, so a name the checker resolved to a user + declaration is lowered as the ordinary call the checker typed. + + Not for the QUALIFIED spelling: ``State.get(())`` names the effect, + so no declaration can shadow it and the registries answer directly. + """ + return not bare_call_denotes_user_fn(name, self._known_fns) + def alloc_param(self) -> int: """Allocate a parameter slot (already in WASM signature). @@ -1124,7 +1161,12 @@ def _is_void_expr(self, expr: ast.Expr) -> bool: return True if isinstance(expr, ast.UnitLit): return True - if isinstance(expr, ast.FnCall) and expr.name in self._effect_ops: + # #1284: bare form, so the ownership predicate decides whether the + # op registry answers at all — a user `fn put` returning a value is + # not void just because the handler's `put` is. + if (isinstance(expr, ast.FnCall) + and self._bare_call_denotes_op(expr.name) + and expr.name in self._effect_ops): _name, is_void = self._effect_ops[expr.name] return is_void # User-defined fns declared with @Unit return type — registry stores diff --git a/vera/wasm/inference.py b/vera/wasm/inference.py index a384bb87..dddff2ea 100644 --- a/vera/wasm/inference.py +++ b/vera/wasm/inference.py @@ -526,11 +526,13 @@ def _infer_fncall_wasm_type(self, expr: ast.FnCall) -> str | None: # a fn declaring `>`) is an `ast.FnCall`, not a # `QualifiedCall`. Its result WAT type is the op's registered # result type — needed when the call sits directly in a - # constructor-argument (A1) or match-scrutinee (A2) position. The - # `_effect_ops` guard in codegen/functions.py only registers ops a - # user fn does NOT shadow, so a same-named user fn still reaches the - # `_fn_ret_types` lookup below. - if expr.name in self._effect_ops: + # constructor-argument (A1) or match-scrutinee (A2) position. + # #1284: the registry is complete (it says which cell an op name + # reaches, shadowed or not), so whether THIS site is the op is the + # ownership predicate's question — a same-named user fn falls + # through to the `_fn_ret_types` lookup below, the same answer the + # dispatch in `_translate_call` will emit a call for. + if self._bare_call_denotes_op(expr.name) and expr.name in self._effect_ops: _target, is_void = self._effect_ops[expr.name] if expr.name == "throw" or is_void: return None @@ -1030,13 +1032,15 @@ def _infer_vera_type(self, expr: ast.Expr) -> str | None: # #1006: an effect op in a Vera-type-needing position (the # array-literal ELEMENT case) — the op is not in the fn tables, # so consult the op registry first. Guarded on `_effect_ops` - # membership: op names only bind where ops are injected, and a - # user fn shadowing an op name is kept OUT of `_effect_ops` (the - # `_fn_sigs` guard at both injection sites), so a shadowed name - # still resolves through the normal fn path below. `get` maps - # to State's T; `put`/`throw` record no Vera result type and - # return None (unchanged skip for value-position uses). - if expr.name in self._effect_ops: + # membership (op names only bind where ops are injected) AND on + # bare-call ownership (#1284), so a user fn shadowing an op name + # resolves through the normal fn path below — the registry + # itself no longer withholds the name, because it answers which + # cell the op reaches rather than whose name this is. `get` + # maps to State's T; `put`/`throw` record no Vera result type + # and return None (unchanged skip for value-position uses). + if (self._bare_call_denotes_op(expr.name) + and expr.name in self._effect_ops): return self._effect_op_result_vera.get(expr.name) return self._infer_fncall_vera_type(expr) if isinstance(expr, ast.StringLit): diff --git a/vera/wasm/operators.py b/vera/wasm/operators.py index 313b12fc..0dd2d9cf 100644 --- a/vera/wasm/operators.py +++ b/vera/wasm/operators.py @@ -1774,18 +1774,38 @@ def _translate_old_expr(self, expr: ast.OldExpr) -> list[str] | None: local_idx = self.get_old_state_local( self._state_effect_family(expr.effect_ref)) if local_idx is None: - raise CodegenInvariantError( # pragma: no cover + # Reachable, and pinned: the checker accepts a contract naming a + # `State` the function's effect row does not declare, so there + # is no snapshot to read. E699 says exactly that ("the type + # checker should have rejected the input"), which is the honest + # report until it does — tracked as #1298. + raise CodegenInvariantError( "old(State) has no saved pre-execution state local", expr) return [f"local.get {local_idx}"] def _translate_new_expr(self, expr: ast.NewExpr) -> list[str] | None: """Translate new(State) → call state_get to read current value.""" state_type_arg(expr.effect_ref) # shape validation; raises otherwise - # Look up the state getter import - if "get" not in self._effect_ops: - raise CodegenInvariantError( # pragma: no cover - "new(State) has no 'get' effect op registered", expr) - call_target, _is_void = self._effect_ops["get"] + # Keyed by the cell FAMILY, exactly as `_translate_old_expr` above + # keys the snapshot map — one derivation, `_state_effect_family`, on + # both sides of the same `ensures` clause (#1285). This used to read + # the name-keyed `_effect_ops["get"]`, which holds whichever family + # the ROW registered first: under `effects(, State>)` + # a `new(State)` took `state_get_Int`, feeding an i64 into the + # Bool comparison's `i32.eq` — check-green, verify-green, and dead at + # load with wasmtime's raw type mismatch. A bare `get(())` names no + # family and so is right to read the name-keyed registry; a contract + # names one and must not. + family = self._state_effect_family(expr.effect_ref) + call_target = self._state_getters.get(family) + if call_target is None: + # The `old()` twin above, reached the same way and pinned beside + # it: a contract naming a family the row does not declare has no + # cell to read. Pre-#1285 this branch could not fire, because + # the name-keyed lookup found SOME getter and read the wrong + # cell — the defect, in its purest form. + raise CodegenInvariantError( + f"new(State<{family}>) has no registered state getter", expr) return [f"call {call_target}"] # ----------------------------------------------------------------- From bcd2943845cdb5e777fabc07678d44a6a9d35c63 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Thu, 13 Aug 2026 23:55:52 +0100 Subject: [PATCH 02/69] Close the fourth op-first consumer, and say what the tables really do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial-review follow-up on c56c55a3. S1 — `_collect_expr_effects`, the checker's `async(e)` commutativity walk, asked `lookup_effect_op` BEFORE `_lookup_function_scoped`: the one op-first consumer left, in the file the ownership predicate was added to. A user function named after a built-in operation contributed the OPERATION's parent effect instead of its own declared row, wrong in both directions and both measured. A PURE `fn get`, in a program with no State anywhere, drew `[W002] async argument performs State effects`. A `fn get` performing IO, under a row naming `Http` first, drew NO warning — the walk bound the name to `Http.get`, which is inside the commutative whitelist, and withheld the warning the program is owed. Now declarations-first through the same predicate, so the analysis reasons about the row the checker actually bound, and the comment claiming it resolves "like the call checker above" is true. Each direction is pinned with its rename control (`gett` / `fetch`, correct throughout, so the name is isolated as the cause) and with a control that an unshadowed bare `get(())` under a `State` row still warns — without which deleting the op lookup outright would pass every case. The sole consumer of the walk is W002, so that is the whole blast radius; all 256 pre-existing corpus programs stay byte-identical. M2 — `bare_call_denotes_user_fn`'s residual-divergence paragraph described a `where`-helper divergence that does not exist and omitted the one that does. Measured: a local helper is registered as `holder$where$get`, never bare, its holder's call sites are rewritten with it, and a sibling function's bare `get` correctly reaches the intrinsic (probe returns the cell's 42, the checker's answer). What is real is #1299: `_fn_sigs` keys an import the call site cannot see under its BARE name, so the predicate answers "user-owned" where the checker resolved the operation — check-green source, silent wrong value on one route and a load failure on the other. Rewritten to state that, with the reference, and to say it is a property of the TABLE rather than of the rule. N2 — both consumer lists omitted `_handler_always_throws`'s `throw_installed`, which asks the same question for `Exn`'s operation. Added to `slots.py` and to `_bare_call_denotes_op`, along with the `async` walk above. KNOWN_ISSUES gains the #1299 row, one-to-one with the tracker. Spec §7.4's declarations-first sentence stands unchanged: it states the language rule the checker implements, and #1299 now tracks codegen's violation of it — the rule in the spec, the gap in the row. Co-Authored-By: Claude --- CHANGELOG.md | 2 +- FAQ.md | 2 +- KNOWN_ISSUES.md | 1 + README.md | 2 +- ROADMAP.md | 2 +- TESTING.md | 4 +- docs/llms-full.txt | 2 +- tests/test_checker_effects.py | 122 ++++++++++++++++++++++++++++++++++ vera/README.md | 2 +- vera/checker/calls.py | 46 +++++++++---- vera/slots.py | 29 ++++++-- vera/wasm/context.py | 15 +++-- 12 files changed, 198 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28ab9305..3b44d54a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed -- **A user-defined `fn get` / `fn put` is no longer hijacked by an enclosing handler** ([#1284](https://github.com/aallan/vera/issues/1284)). Three sites answered "does this `get` mean the user's declaration or the effect operation?" independently. The checker answers user-fn-first — `_check_call_with_args` looks a bare name up as a function before it looks it up as an operation, so a declaration named `get` owns every bare `get(...)` in its scope, which an arity or argument-type error at such a call site proves by reporting the *user's* signature (E201/E202). Codegen answered twice more: the declared-effect row in `vera/codegen/functions.py` withheld the intrinsic when `_fn_sigs` already owned the name, and the handler expression in `vera/wasm/calls_handlers.py` installed `get`/`put` unconditionally. From `vera check`-green source that produced, depending on the nesting shape, a **silently wrong value** (`nat_to_int(get(3))` under `handle[State](@Int = 5)` returned the cell's 5 for the function's 4, and the argument was not even emitted), a **module WASM validation rejects** (a `@Bool`-returning user `get` took `state_get_Int`'s i64 into an `i32` position; different-family nesting took the *enclosing* cell's getter at the wrong width), or a **spurious `[E602]`** in which the #1233 unaddressable-cell gate refused `main` outright, naming "a bare or qualified State operation `get`" the program never contained. The repair is one predicate, `vera.slots.bare_call_denotes_user_fn`, stating the checker's rule once and consumed by the bare-call dispatch in `vera/wasm/calls.py` (which now gates the clause-inline registry, the host-cell intrinsics and the addressability gate together), by the three bare-`FnCall` result-type inference sites, and by the monomorphizer's discovery walk — each passing its own name table, so the sites cannot answer differently about the table they share. Gating the *dispatch* rather than the *registries* is what makes it correct rather than merely consistent: the registries record which cell an op name reaches, which is true whatever the program's declarations are called, and withholding an entry answered both questions with one table. That is why the gate-only fix measured during PR #1283's review turned the loud skip into a differently-broken module, and why it also cost the qualified spelling its cell — `State.put(5)` in a function that also declares `fn put` compiled to `call $vera.put` and failed to link, which now lowers to the intrinsic the checker always meant. Discovery's `MonoContext.fn_names` moves to the same lookup-time question, so a `get(())` fixing a generic's type variable under a handler names the clone the rewrite emits. All 256 conformance and example programs emit byte-identical WAT. +- **A user-defined `fn get` / `fn put` is no longer hijacked by an enclosing handler** ([#1284](https://github.com/aallan/vera/issues/1284)). Three sites answered "does this `get` mean the user's declaration or the effect operation?" independently. The checker answers user-fn-first — `_check_call_with_args` looks a bare name up as a function before it looks it up as an operation, so a declaration named `get` owns every bare `get(...)` in its scope, which an arity or argument-type error at such a call site proves by reporting the *user's* signature (E201/E202). Codegen answered twice more: the declared-effect row in `vera/codegen/functions.py` withheld the intrinsic when `_fn_sigs` already owned the name, and the handler expression in `vera/wasm/calls_handlers.py` installed `get`/`put` unconditionally. From `vera check`-green source that produced, depending on the nesting shape, a **silently wrong value** (`nat_to_int(get(3))` under `handle[State](@Int = 5)` returned the cell's 5 for the function's 4, and the argument was not even emitted), a **module WASM validation rejects** (a `@Bool`-returning user `get` took `state_get_Int`'s i64 into an `i32` position; different-family nesting took the *enclosing* cell's getter at the wrong width), or a **spurious `[E602]`** in which the #1233 unaddressable-cell gate refused `main` outright, naming "a bare or qualified State operation `get`" the program never contained. The repair is one predicate, `vera.slots.bare_call_denotes_user_fn`, stating the checker's rule once and consumed by the bare-call dispatch in `vera/wasm/calls.py` (which now gates the clause-inline registry, the host-cell intrinsics and the addressability gate together), by the three bare-`FnCall` result-type inference sites, and by the monomorphizer's discovery walk — each passing its own name table, so the sites cannot answer differently about the table they share. Gating the *dispatch* rather than the *registries* is what makes it correct rather than merely consistent: the registries record which cell an op name reaches, which is true whatever the program's declarations are called, and withholding an entry answered both questions with one table. That is why the gate-only fix measured during PR #1283's review turned the loud skip into a differently-broken module, and why it also cost the qualified spelling its cell — `State.put(5)` in a function that also declares `fn put` compiled to `call $vera.put` and failed to link, which now lowers to the intrinsic the checker always meant. Discovery's `MonoContext.fn_names` moves to the same lookup-time question, so a `get(())` fixing a generic's type variable under a handler names the clone the rewrite emits. All 256 conformance and example programs emit byte-identical WAT. **The `W002` async-commutativity warning was the same defect in the checker's own file** and is corrected with them: `_collect_expr_effects` asked `lookup_effect_op` before the scoped function lookup — the last op-first consumer — so a user function named after an operation contributed the *operation's* parent effect to the commutativity analysis instead of its own declared row, wrong in both directions. A **pure** `fn get`, in a program containing no `State` at all, drew `async argument performs State effects`; a `fn get` that performs `IO`, under a row naming `Http` first, drew **no warning**, because the walk bound the name to `Http.get`, which is inside the commutative whitelist, and silently withheld the eager-evaluation warning the program is owed. Both are pinned with rename controls — the byte-identical program with the helper called `gett` / `fetch` was correct throughout — beside a control that an unshadowed bare `get(())` under a `State` row still warns, so the fix cannot degenerate into never reporting `State`. The walk's comment claiming it resolves "like the call checker above" is now true. One caveat the predicate does not close: codegen's name table is not scope-accurate, so an import the call site cannot see still answers "user-owned" there — a silent wrong value and a load failure from check-green source, tracked as [#1299](https://github.com/aallan/vera/issues/1299), a property of the table rather than of the rule. - **`new(State)` reads the cell its contract names** ([#1285](https://github.com/aallan/vera/issues/1285)). `old(State)` has been keyed on the resolved cell family since [#1205](https://github.com/aallan/vera/issues/1205)/[#1209](https://github.com/aallan/vera/issues/1209); `new(State)` read the name-keyed `_effect_ops["get"]`, which holds whichever `State` the effect row registered first. Under a single-`State` row the two keyings coincide, which is why the corpus agreed; under a multi-`State` row the two sides of one `ensures` clause read different cells. `effects(, State>)` with `ensures(new(State) == false)` was check-green *and* verify-green, emitted `state_get_Int`'s i64 into the Bool comparison's `i32.eq`, and died at load with wasmtime's raw `type mismatch: expected i32, found i64`. The type mismatch is the symptom rather than the defect: where both cells share a machine width — `State` beside `State` — the module loaded and answered about the other cell, so `ensures(new(State) == old(State))` on a function that writes neither was refuted at runtime on a contract the verifier had discharged. Codegen now carries a family→getter registry populated at the declared-row registration site from the per-family `CellNames` it already computes, and `_translate_new_expr` keys on `_state_effect_family` exactly as `_translate_old_expr` does. A bare `get(())` names no family and so is right to keep reading the name-keyed registry, source-order-first-wins; a contract names one and must not. diff --git a/FAQ.md b/FAQ.md index 1fadedae..6b5c0b61 100644 --- a/FAQ.md +++ b/FAQ.md @@ -279,7 +279,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 10,532 tests, including a 216-program conformance suite +- 10,537 tests, including a 216-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index c6b286b9..c58c979d 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -8,6 +8,7 @@ Defects in shipped compiler, runtime, or tooling behaviour — this table matche | Bug | Issue | |-----|-------| +| Codegen's `_known_fns` mirrors the flat `_fn_sigs`, which keys an import the call site **cannot see** under its bare name, so the #1284 ownership predicate answers "user-owned" at a site where the checker resolved the effect operation — one rule, two tables that disagree. Two routes: a `private fn get` in an imported module (still reachable, so still emitted) and a `public fn get` a selective import excludes. From check-green source: where both cells are i64 the module loads and returns the helper's value instead of the cell's (**silent wrong value**), and where the widths differ it fails to load (`type mismatch: expected i64, found i32`). The checker's answer is nailed by a type oracle rather than asserted — the invisible `get` returns `@Bool` while the caller returns `@Int` from `get(())` and checks green, which is only possible if the checker typed the call from the `State` cell. Pre-existing, not introduced by the #1284 fix, which made the rule one function without making either table scope-accurate. Fix direction: `_known_fns` becomes the names visible at the compiling declaration's module scope, or the flattening stops keying non-visible imports bare — the `mod$…` renaming `_resolve_module_call_wasm_name` already applies to shadowed generics is the precedent. Same module-namespace table family as [#1281](https://github.com/aallan/vera/issues/1281). Spec §7.4 states the language rule ("declarations first — a property of the call site's scope alone"); this row tracks codegen's violation of it. | [#1299](https://github.com/aallan/vera/issues/1299) | | A postcondition may name a `State` the function's effect row never declares, and `vera check` accepts it: `ensures(new(State) == false)` under `effects(>)` reports OK, then `vera compile` fails with **E699** — the internal-compiler-error diagnostic whose own text says the type checker should have rejected the input, which is exactly the situation. Both forms land there for the same reason (no cell of that family exists, so `old()` finds no snapshot local and, since [#1285](https://github.com/aallan/vera/issues/1285), `new()` finds no getter). Loud and never a wrong answer, so this is diagnostic quality rather than soundness — but it is a check-green program that cannot compile, reported against the compiler instead of against the program, with a bug-report request the user should not act on. Before #1285 the `new()` side was worse than loud: the name-keyed lookup found the row's other getter and silently read the wrong cell. Fix direction: validate an `OldExpr`/`NewExpr`'s effect reference against the declared row where the checker already validates the rest of the clause, one rule for both forms; `test_a_family_the_row_does_not_declare_is_loud_on_both_sides` pins today's E699 and is the test to flip. | [#1298](https://github.com/aallan/vera/issues/1298) | | Spec §1.4 says its reserved keywords MUST NOT be used as function names, and for **seventeen of them nothing enforces it**: `then`, `else`, `data`, `type`, `module`, `import`, `public`, `private`, `requires`, `ensures`, `invariant`, `decreases`, `effect`, `with`, `in`, `where` and `pure` all declare and pass `vera check` as `public fn (@Int -> @Int)`. (`handle` is an eighteenth acceptance and is the sanctioned host-entry-point carve-out, §5.2; the other eleven names — `resume`, `exists`, `forall`, `match`, `assert`, `assume`, `fn`, `let`, `if`, `true`, `false` — are refused **E153**.) They are not even traps: `private fn with(@Int -> @Int)` and its `where` / `type` / `pure` / `requires` / `import` siblings are **callable** from the same file, so the declaration resolves and runs like any other function. The comment above `_KEYWORD_FN_NAMES` in `vera/checker/registration.py` gives the opposite as the reason those names are absent from the set — that the contextual lexer "does not admit them as a function name, so no declaration reaches this checker at all" — so the omission rests on a premise the tree refutes, and neither the parser nor the checker holds the MUST. Two resolutions, and the choice is a ruling rather than a mechanical fix: extend `_KEYWORD_FN_NAMES` so the seventeen join `resume` under E153, or narrow §1.4's MUST to the names actually reserved. The `resume` precedent leans to enforcing — `resume` is likewise not a keyword token anywhere and likewise parses, and was reserved *at the checker* precisely because the parser had no keyword to refuse it with — and E153's own rationale, that a keyword names one construct and nothing else (DESIGN principle 3), does not depend on the name being a trap. Until the ruling lands, §1.4's `handle` carve-out sentence deliberately makes no enforcement claim, so the spec does not assert a MUST it cannot back. | [#1296](https://github.com/aallan/vera/issues/1296) | | The browser runtime's `md_render` breaks the round-trip property spec §9.7.6 states for it — `md_parse(md_render(b)) == Ok(b)` does not hold for its output — and with it §12.9.3's identical-results requirement, which names Markdown explicitly. The scope is **any multi-line paragraph**, not the list lazy continuation first observed: the browser preserves a paragraph's internal soft line breaks and does not re-apply the container prefix (`> `, list-item indent) on output, where the native renderer collapses them to spaces. The browser render is also **not stable** — re-rendering its own output moves content out of its container, so `> a b` renders as two lines and then re-renders with the second line no longer quoted — and on a blockquote wrapping a heading and a fenced block it destroys the document: the fence fragments into three and its contents escape the quote, past recovery by any subsequent parse. The native renderer is a fixed point on every case. Parsing is unaffected — `md_has_heading`, `md_has_code_block` and `md_extract_code_blocks` are byte-identical across runtimes on the same inputs — so the defect is scoped precisely to the renderer in `vera/browser/runtime.mjs`. `tests/test_browser.py` pins both runtimes' exact current strings, including the destructive blockquote case, so a fix on either side goes red until the pins are updated deliberately. | [#1294](https://github.com/aallan/vera/issues/1294) | diff --git a/README.md b/README.md index a96b2a81..b8a9c7d2 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ cp /path/to/vera/SKILL.md ~/.claude/skills/vera-language/SKILL.md ## Project status -Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 10,532 tests, 95% Python code coverage, 216 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. +Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 10,537 tests, 95% Python code coverage, 216 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. The reference compiler — parser, AST, type checker, contract verifier (Z3), WASM code generator, module system, browser runtime, and runtime contract insertion — is working. The language specification is in draft across [14 chapters](spec/). diff --git a/ROADMAP.md b/ROADMAP.md index 6bcfd5c2..eeb5b418 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,7 +12,7 @@ Ordering derives from the design principles ([DESIGN.md](DESIGN.md)): verificati ## Where we are -10,532 tests, 216 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. +10,537 tests, 216 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. ## Stage 19 — The verification completeness sprint diff --git a/TESTING.md b/TESTING.md index 730aa375..ce688821 100644 --- a/TESTING.md +++ b/TESTING.md @@ -6,7 +6,7 @@ This is the single source of truth for Vera's testing infrastructure, coverage d | Metric | Value | |--------|-------| -| **Tests** | 10,532 across 164 files (~152,000 lines of test code; 10,364 passed + 26 stress, 142 skipped) | +| **Tests** | 10,537 across 164 files (~152,000 lines of test code; 10,369 passed + 26 stress, 142 skipped) | | **Compiler code coverage** | 95% Python, 87% JavaScript (CI minimum: 80%) | | **Conformance programs** | 216 programs across 9 spec chapters, validating every language feature | | **Example programs** | 42, all validated through `vera check` + `vera verify` | @@ -70,7 +70,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_alias_application_refinement_base_1237.py` | 11 | 398 | A parameterised alias APPLICATION substitutes its arguments in the verifier's own resolver (#1237). `type Box = T;` applied as `@Box` resolved to `AdtType('T')` — the alias's binder leaking as an ADT name — so a refinement over it failed the modelled-primitive gate, the refined-return fact was dropped, and a valid program was rejected with a spurious E501 while `vera run` returned the right answer. Both halves of the fix are asserted separately: the alias body registers its own parameters as type variables (`substitute` maps type variables, so an ADT-named binder is unsubstitutable however the application side is written) and the application substitutes. Plus depth (an argument that is itself an application, and an alias whose body applies another alias), the bounded direction (a consumer wanting `>= 100` where the refinement grants `>= 18` is still rejected, and the runtime agrees), and the gate that must NOT move — an unmodelled `@Byte` base resolves correctly and still degrades to a Tier-3 runtime check, with a consumer of its predicate still refused | | `test_exn_throw_payload_1268.py` | 20 | 453 | `throw`'s payload is obligated like every other narrowing site (#1268). `throw(0 - 5)` under `effects(>)` verified at 4/4 Tier 1 with ZERO obligations while `vera run` returned -5 out of the `@Nat` payload — check-green, verify-green, silently wrong — because `throw` is a bare call with no function-registry entry, so the argument loop never saw it and the table-driven fallback added for the same hole at the State `put` was keyed on that one name. All three arms are asserted (the `@Nat` refutation, the refined refutation over a modelled base, and the `@Nat`->`@Int` widening obligation appearing where none existed), plus the #1251(b) concrete gate reaching the payload for free (`throw(200)` into an `Exn<{ @Byte \| @Byte.0 < 10 }>` names the value; the satisfying twin proves at Tier 1), the user-effect contrast that localized the bug (a declared op's argument was loud for the same value — both are loud now, at the same site name), and the refined-alias payload spelling. The unguarded disclosure is checked against codegen rather than asserted: an undischargeable payload must land on the `tier3_unguarded` E504 leg (excluded from the totals), and a run confirms the payload really is unchecked — the two together go red whichever side moves without the other. A dischargeable twin proves the site is not merely always-loud, and the six `Exn` conformance programs are verified whole as canaries so obligating a position that had none names itself instead of arriving as one line of a corpus sweep | | `test_refinement_binder_convergence_1208.py` | 16 | 459 | Codegen's refinement boundary guard and `vera/naming.py` derive the predicate binder ONCE (#1208). A per-shape differential over a direct refinement, an alias hop, `@Nat`- and `@Byte`-based refinements (both range-conjoining), a composite base whose binder is a RESOLVED argument list, and two non-refinements that must be `None` on both sides — plus the property the convergence exists to hold, that the guard's binder equals the key a predicate's own `@Base.n` resolves to. A per-shape differential is green either way while two copies agree — which is exactly how a duplicated derivation drifts unnoticed — so the load-bearing assertion is a MUTATION: perturb `naming.refinement_binder_parts` and codegen's guard must report the perturbed binder. Codegen's two layered WASM decisions are pinned alongside — the loud E618 for a nested refinement base, reported ONCE per declaration however many call sites consult the derivation and however many clones a generic is instantiated into (two genuinely distinct sites still report twice), and no guard at all for an erased one (parametrized over `@Unit` and `Future`, the corner that erases identically but is not spelled `Unit`) — plus runtime traps proving BOTH the `@Byte` and the `@Nat` range conjunctions reach the emitted check, each pinned by trap `kind` as well as by the conjunct its message names. The mutation perturbs the predicate as well as the binder name: the two travel by different routes, and the range conjunction lives on the predicate. The once-per-site dedup is keyed on a resolved location, so a cross-module pin holds up the premise that a location carries its owning file: two imported library modules of identical shape, declaring their nested refinement at the same line and column, must produce two diagnostics attributed to two files — each quoting its own module's declaration, which is also what catches an attribution pointing past the importer's last line | -| `test_checker_effects.py` | 85 | 1,333 | Effect declarations, abilities, effect subtyping, async effect, handler typing (#420 split), and the #1149 built-in-effect redeclaration gate (E152: divergent and faithful `effect IO`, codegen-only `Exn`, a registry-parametrised sweep, and a differential pinning the gate's name set to what `vera effects --json` publishes) | +| `test_checker_effects.py` | 90 | 1,455 | Effect declarations, abilities, effect subtyping, async effect, handler typing (#420 split), and the #1149 built-in-effect redeclaration gate (E152: divergent and faithful `effect IO`, codegen-only `Exn`, a registry-parametrised sweep, and a differential pinning the gate's name set to what `vera effects --json` publishes) | | `test_state_exn_registration.py` | 30 | 1296 | #1210 — State/Exn host-import registration covers the whole handler, not just its body. Four shapes, one per sub-expression position the walk used to miss (a nested handler in a clause body, in the state-init expression, in a clause's `with` update, and an `Exn` handler in a clause body), each check-green and verify-clean and therefore required to COMPILE — pre-fix every one died at whole-module WAT compilation with `unknown func` / `unknown tag`; plus the `i32_pair` cell (`handle[State]` in a `pure` function) that the walk skipped in silence, now the same loud E607 the declared-effect gate emits. The **registration-completeness differential** is the cross-component invariant itself: over every `examples/` + `tests/conformance/` program that compiles, every `state_*` / `exn_*` symbol the emitted WAT REFERENCES must have a matching import or tag DECLARATION — a desync between the registration pass and the lowering pass is invisible to a unit test on either. Round two adds the Exn twin of the silent skip (`handle[Exn]` in a `pure` function — the walk called the shared tag registration and discarded the verdict, so it compiled where the declared-row spelling was a clean E612) and the four CONTRACT positions, which are lowered code: a handler in a `requires`, an `ensures`, an `assert`, or a `decreases` measure. The differential gained a **validation leg** — every HANDLER-BEARING module is handed to `wasmtime.Module` through the exceptions-enabled engine `execute()` uses, because a symbol declared at the WRONG TYPE passes the name comparison while being invalid WASM, and 10 of the 30 handler-bearing modules fail to load with `wasm_exceptions` off — a supported wasmtime configuration, though the current runner defaults it on. The conformance suite's deliberate negatives are filtered out of the sweep: they never reach codegen through `vera check`. Carries floors on programs swept, modules validated, summed symbol references and globally distinct symbols, plus three can-go-red tests: the State and Exn extractions each stripped of their declaration lines, and a planted retyped import that only the validation leg catches. Round five adds the three positions no corpus program contained — a destructuring `let`'s value (which also disarmed the E612 gate), a module call's ARGUMENTS, and a signature refinement predicate reached through the alias table — and a cross-module shape test for the module-call leg. Round seven adds the boundary-guard routes that enumeration missed (a tuple parameter's components, a tuple return's, and a closure's refined formal and return) and the co-extensiveness half those shapes cannot show: a refined tuple behind a CLOSURE formal must declare nothing, since the closure path emits no component guards, and the nested-refinement (E618) and erased-base bails must stay silent registrars too. Plus the cycle guard on the closure signature leg, asserted in both directions — the walk terminates, and with the guard neutered the same walk blows the recursion limit | | `test_closure_lift_boundaries_1234_1235_1245.py` | 18 | 757 | Closure lifting at refinement boundaries — three burndown defects of one seam. **#1245**: `_lift_pending_closures` ran BEFORE `_compile_postconditions`, so a closure created while lowering a refined-RETURN guard, a tuple return's component guards, or an `ensures(...)` predicate was registered and never lifted — the table stayed empty, its `call_indirect` was orphaned, and the #1185 propagation dropped the function and every caller: a check-green, verify-clean program compiling to ZERO exports. The param-position twin (lowered before the lift, so it always worked) is carried as the control that makes it an ORDERING defect, the `ensures`-clause twin shows the same bug with no refinement in sight, and a violating return asserts the lifted guard ENFORCES rather than merely existing. **#1234**: the lift worklist fed itself — a refinement whose predicate holds a closure refined by a type whose chain leads back to it (`type SelfRef = { @Int \| ... fn(@SelfRef -> @Int) ... }`, and equally a mutual `A -> B -> A` or a three-type cycle) had each lift's own boundary guard queue an `AnonFn` for ever, and `vera compile` never returned. All three cycle lengths are asserted on a daemon thread with a wall-clock budget, so a regression fails fast instead of hanging the suite, and each on the [E602] naming the closure it refused (a guard that never fired cannot produce it). Two controls carry the other half — the guard is keyed on the lift CHAIN, not on everything already lifted, so `fn f(@R, @R -> @Int)` and a diamond, which each legitimately lift one predicate's closure twice, must still run; mutation-measured, they are the only two tests a seen-set spelling reddens. **#1235**: a `Tuple` formal crossing into a closure was unguarded where the named path traps — both spellings of the same boundary are run against each other, violating and passing | | `test_byte_literal_joins_1212.py` | 25 | 759 | #1212 — a `@Byte` literal inside a value-position join lowers at the i32 Byte width. `@Byte` is i32 (spec §11) while an int literal defaults to `i64.const`, and the #865 / #1092 coercions each tested for a TOP-LEVEL `IntLit` — which the checker's bidirectional coercion is equally happy to type inside an `if` or `match` branch. Ten write boundaries are parametrized with the literal in a branch (`let`, handler state-init, clause-dispatched `put`, bare `put`, get-clause `resume` — verbatim the form the E602 clause-lowerability skip message recommends — a clause `with` update, a `@Byte` call argument, a generic constructor field at `Box`, a lifted closure's own RETURN whose named twin had been coerced since #865 while the closure path had no such step, and a HETEROGENEOUS join at a `@Byte` return, where the arm the result-type decider reads is already i32 and a sibling is a bare literal — arm ORDER decided which way that one failed, so both orders and both paths are pinned). The module docstring states what that list is and is not: measured coverage, since the checker's single Byte coercion makes the true enumeration "every position propagating a Byte expectation", which nothing enumerates in one place, each a check-green program that failed WASM validation with `type mismatch: expected i32, found i64` before the fix. Every case carries a VALUE oracle (200, distinguishable from every other constant in its fixture) rather than merely asserting the module runs, and a separate test drives the OTHER branch so a fix that marked only the arm the result type is read off would still fail. The controls are the load-bearing half: a plain `@Int` join must stay i64 — pinned on 5,000,000,000, which an i32 store cannot represent, so a spreading mark is a wrong VALUE and not just a validation failure — a Byte join with no literal arm must be untouched, and a Byte-RETURNING literal join must keep its own #865 return coercion. The constructor-field case runs through the real pipeline (checker artifacts threaded), because the #1092 width keys on the checker-recorded target type | diff --git a/docs/llms-full.txt b/docs/llms-full.txt index f3db2b08..cef97513 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -3256,7 +3256,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 10,532 tests, including a 216-program conformance suite +- 10,537 tests, including a 216-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/tests/test_checker_effects.py b/tests/test_checker_effects.py index 401f4f44..a7bd15a8 100644 --- a/tests/test_checker_effects.py +++ b/tests/test_checker_effects.py @@ -891,6 +891,128 @@ def test_async_over_http_via_fn_call_no_warning(self) -> None: """) assert not any(w.error_code == "W002" for w in warnings), warnings + # --- #1284: the commutativity walk resolves declarations first ---- + # + # `_collect_expr_effects` asked `lookup_effect_op` BEFORE + # `_lookup_function_scoped`, the one op-first consumer left in the file. + # A user function named after a built-in operation therefore contributed + # the OPERATION's parent effect to the commutativity analysis instead of + # its own declared row — wrong in both directions, and each pair below + # carries the rename control that isolates the name as the cause. + + def test_async_over_pure_user_get_does_not_warn(self) -> None: + """A PURE user `fn get`, in a program with no State anywhere. + + pre_fix: `[W002] async argument performs State effects` — the walk + found the built-in `State.get` and reported a cell the program does + not have. The rename control below is byte-identical but for the + name, so the warning was a property of the spelling alone. + """ + warnings = _warnings(""" +private fn get(@Int -> @Int) + requires(true) ensures(@Int.result == @Int.0 + 1) effects(pure) +{ @Int.0 + 1 } + +public fn main(@Unit -> @Int) + requires(true) ensures(true) effects() +{ + let @Future = async(get(3)); + await(@Future.0) +} +""") + assert not any(w.error_code == "W002" for w in warnings), [ + w.description for w in warnings + ] + + def test_async_over_pure_helper_under_another_name_does_not_warn( + self, + ) -> None: + """The rename control: `gett` was clean before the fix and after.""" + warnings = _warnings(""" +private fn gett(@Int -> @Int) + requires(true) ensures(@Int.result == @Int.0 + 1) effects(pure) +{ @Int.0 + 1 } + +public fn main(@Unit -> @Int) + requires(true) ensures(true) effects() +{ + let @Future = async(gett(3)); + await(@Future.0) +} +""") + assert not any(w.error_code == "W002" for w in warnings), warnings + + def test_async_over_effectful_user_get_still_warns(self) -> None: + """The other direction: a user `fn get` performing IO, under a row + naming `Http` first. + + pre_fix: NO warning at all. Op-first resolution bound the name to + `Http.get`, which IS in the commutative whitelist, so the walk + concluded the argument commutes and withheld the warning the + program is owed — the silent direction, and the reason this pair + needs the rename control below rather than the value alone. + """ + warnings = _warnings(""" +private fn get(@Int -> @Int) + requires(true) ensures(true) effects() +{ + IO.print("side effect"); + @Int.0 + 1 +} + +public fn main(@Unit -> @Int) + requires(true) ensures(true) effects() +{ + let @Future = async(get(3)); + await(@Future.0) +} +""") + w002 = [w for w in warnings if w.error_code == "W002"] + assert w002, f"expected W002, got: {[w.error_code for w in warnings]}" + assert "IO" in w002[0].description, w002[0].description + + def test_async_over_effectful_helper_under_another_name_warns( + self, + ) -> None: + """The rename control for the withheld direction: `fetch` warned + before the fix, so the difference is the name and nothing else.""" + warnings = _warnings(""" +private fn fetch(@Int -> @Int) + requires(true) ensures(true) effects() +{ + IO.print("side effect"); + @Int.0 + 1 +} + +public fn main(@Unit -> @Int) + requires(true) ensures(true) effects() +{ + let @Future = async(fetch(3)); + await(@Future.0) +} +""") + w002 = [w for w in warnings if w.error_code == "W002"] + assert w002, f"expected W002, got: {[w.error_code for w in warnings]}" + assert "IO" in w002[0].description, w002[0].description + + def test_async_over_a_real_state_op_still_warns(self) -> None: + """The control that keeps the fix from degenerating into "never + report State": an UNSHADOWED bare `get(())` under a `State` row + is the operation, and still warns. Without this, deleting the + op lookup entirely would pass every case above. + """ + warnings = _warnings(""" +public fn main(@Unit -> @Int) + requires(true) ensures(true) effects(, Async>) +{ + let @Future = async(get(())); + await(@Future.0) +} +""") + w002 = [w for w in warnings if w.error_code == "W002"] + assert w002, f"expected W002, got: {[w.error_code for w in warnings]}" + assert "State" in w002[0].description, w002[0].description + # ===================================================================== # Coverage: control.py — handler type-checking diff --git a/vera/README.md b/vera/README.md index 23f2eaff..879682a0 100644 --- a/vera/README.md +++ b/vera/README.md @@ -752,7 +752,7 @@ The `ERROR_CODES` dict in `errors.py` maps every code to a short description (15 ## Test Suite -Testing spans a **pytest suite** of 10,532 tests across 164 files — compiler-internals unit tests plus a **conformance suite** (216 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. +Testing spans a **pytest suite** of 10,537 tests across 164 files — compiler-internals unit tests plus a **conformance suite** (216 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. See **[TESTING.md](../TESTING.md)** for the comprehensive testing reference -- test file table, conformance suite details, compiler code coverage, language feature coverage, helper conventions, validation scripts, CI pipeline, and guidelines for adding tests. diff --git a/vera/checker/calls.py b/vera/checker/calls.py index 640b5155..0fa60502 100644 --- a/vera/checker/calls.py +++ b/vera/checker/calls.py @@ -712,21 +712,41 @@ def _collect_expr_effects(self, node: ast.Node, # from this mixin; conservatively non-commutative. acc.add(f"<{node.qualifier}.{node.name}>") elif isinstance(node, ast.FnCall): - op_info = self.env.lookup_effect_op(node.name) + # #991: resolve lexically like the call checker above, so a + # same-named helper in a sibling tree can't contribute the + # WRONG effect row to the commutativity analysis. + # + # #1284: and DECLARATIONS FIRST, which is the rest of what "like + # the call checker above" means — this walk asked + # `lookup_effect_op` first, so a user function named after a + # built-in operation contributed the OPERATION's parent effect + # instead of its own declared row. Wrong in both directions and + # both measured: a PURE `fn get` in a program containing no + # State at all drew `[W002] async argument performs State + # effects`, and a `fn get` performing IO under a row naming + # `Http` first drew NO warning, because `Http` is inside the + # commutative whitelist and the operation is what the walk + # thought it had found. Same predicate as the resolution at the + # top of this file, so the analysis reasons about the row the + # checker actually bound. + fn_info = ( + self._lookup_function_scoped(node.name) + if bare_call_denotes_user_fn(node.name, self._user_fn_names) + else None + ) + op_info = ( + self.env.lookup_effect_op(node.name) + if fn_info is None else None + ) if op_info is not None: acc.add(op_info.parent_effect) - else: - # #991: resolve lexically like the call checker above, so a - # same-named helper in a sibling tree can't contribute the - # WRONG effect row to the commutativity analysis. - fn_info = self._lookup_function_scoped(node.name) - if fn_info is None: - acc.add(f"<{node.name}>") - elif isinstance(fn_info.effect, ConcreteEffectRow): - for ei in fn_info.effect.effects: - acc.add(ei.name) - elif not isinstance(fn_info.effect, PureEffectRow): - acc.add(f"<{node.name}>") + elif fn_info is None: + acc.add(f"<{node.name}>") + elif isinstance(fn_info.effect, ConcreteEffectRow): + for ei in fn_info.effect.effects: + acc.add(ei.name) + elif not isinstance(fn_info.effect, PureEffectRow): + acc.add(f"<{node.name}>") for field in _dc.fields(node): value = getattr(node, field.name) if isinstance(value, ast.Node): diff --git a/vera/slots.py b/vera/slots.py index 23c380b1..e1e0014d 100644 --- a/vera/slots.py +++ b/vera/slots.py @@ -67,6 +67,9 @@ def bare_call_denotes_user_fn( * the checker's ``_check_call_with_args`` — the derivation, over its LEXICAL function scope (#991); + * the checker's ``_collect_expr_effects``, the ``async(e)`` + commutativity walk, so the row it reasons about is the row the + resolution above bound; * ``_translate_call`` in :mod:`vera.wasm.calls` — the bare dispatch, over codegen's flat ``_fn_sigs`` mirror (``_known_fns``): a user-owned name skips the clause-inline registry, the host-cell intrinsics, and the @@ -74,6 +77,10 @@ def bare_call_denotes_user_fn( * the three bare-``FnCall`` inference sites in :mod:`vera.wasm.inference` and :mod:`vera.wasm.context`, so a shadowed name is typed from the function table rather than from the operation's result registry; + * ``_handler_always_throws`` in :mod:`vera.wasm.calls_handlers`, whose + ``throw_installed`` question is the same one for ``Exn``'s operation: + a bare ``throw`` in a clause body is the op only where no declaration + owns the name; * :class:`~vera.monomorphize.Monomorphizer`'s discovery walk, over ``MonoContext.fn_names``, so the clone it discovers for a ``get(())`` in a value position is the clone the rewrite emits. @@ -88,12 +95,22 @@ def bare_call_denotes_user_fn( *user_fn_names* is a membership view, not a fixed set, so each consumer supplies the table it actually resolves against — the checker a lexical - scope walk, codegen its flat signature keys. Those tables are not - identical (codegen keys a local ``where`` helper by its bare name; #1015 - hoists an imported one to ``parent$where$name``), which is the same - documented imprecision the verifier's ``fn_names`` mirrors — but the - RULE applied to them is now one function, so the sites cannot answer - differently about the table they share. + scope walk, codegen its flat signature keys. One rule over two tables + is one answer only where the tables agree, and they do not everywhere: + codegen's ``_fn_sigs`` keys an import the call site cannot see under its + BARE name, so a ``private fn get`` in an imported module — or a public + one a selective import excludes — makes this predicate answer + "user-owned" at a site where the checker resolved the operation. That + residual is a live defect, tracked as #1299 with a demonstrated silent + wrong value and a demonstrated load failure, and it is a property of the + TABLE rather than of the rule; the fix belongs to ``_known_fns``, which + has to become the names visible at the compiling declaration's module + scope. A ``where`` helper is NOT such a case, though the flat keying + invites the assumption: #1015 and the non-generic hoist move every + helper out of the bare namespace before registration (a local one is + ``holder$where$get``), and the call sites inside its holder are rewritten + with it, so a helper named after an operation shadows the name in its own + body and nowhere else — which is exactly the checker's lexical answer. """ return name in user_fn_names diff --git a/vera/wasm/context.py b/vera/wasm/context.py index 6c67a547..c03a8310 100644 --- a/vera/wasm/context.py +++ b/vera/wasm/context.py @@ -670,13 +670,20 @@ def _bare_call_denotes_op(self, name: str) -> bool: Codegen's leg of :func:`~vera.slots.bare_call_denotes_user_fn`, over the flat ``_fn_sigs`` mirror. Every bare-call site that consults an - op registry — the clause-inline dispatch, the host-cell intrinsics, - the #1233 addressability gate, and the three result-type inference - sites — asks this first, so a name the checker resolved to a user - declaration is lowered as the ordinary call the checker typed. + op registry asks this first, so a name the checker resolved to a user + declaration is lowered as the ordinary call the checker typed: the + clause-inline dispatch, the host-cell intrinsics, the #1233 + addressability gate, the three result-type inference sites, and + ``_handler_always_throws``'s ``throw_installed`` question, which is + the same one for ``Exn``'s operation. Not for the QUALIFIED spelling: ``State.get(())`` names the effect, so no declaration can shadow it and the registries answer directly. + + The ``_fn_sigs`` mirror is not scope-accurate — it keys an import the + call site cannot see under its bare name, so this can answer + "user-owned" where the checker resolved the operation (#1299). The + rule is right and the table is wrong; the fix is that table's. """ return not bare_call_denotes_user_fn(name, self._known_fns) From 15f7c190885076d1f92d773fbb490a337a2d219f Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 00:22:35 +0100 Subject: [PATCH 03/69] Say "not every helper", and name the third route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wording follow-up on bcd29438, whose replacement paragraph over-claimed in the direction it was correcting. It said the hoists move EVERY `where` helper out of the bare namespace, so a helper shadows an operation name in its own body "and nowhere else". True under a NON-generic parent; false under a generic one, which keeps a bare `_fn_sigs` key beside its clone-qualified one — measured on `ch09_generic_where_helper.vera`, whose `helper` and `id` are both keyed bare. Weaponized: a `forall` holder's helper named `get` claims a SIBLING's bare `get(())` under a `State` row, and the program fails to assemble with `unknown func: failed to find name $get` and no E-code, from check-green source; the rename control returns 4207, the checker's answer. So it is a third #1299 route, not a counter-example to it, and the docstring now says so. It is also the LOUD one, for a reason the row now records: the bare key exists in the signature TABLE while no bare SYMBOL is ever emitted (the helper is only `holder$Bool$where$get`), so the call dangles at WAT assembly instead of reaching a wrong function. The two import routes remain the dangerous ones — silent wrong value where the cells share a width, load failure where they do not. The row's fix direction is corrected with it: module scope is not enough, because a generic's `where` helper IS in the module and still is not in a sibling's scope. `_known_fns` has to carry the names visible in the compiling declaration's LEXICAL scope. Docs only — docstring prose in `slots.py` and `_bare_call_denotes_op`, the KNOWN_ISSUES row, and the CHANGELOG caveat clause. No executable line changes. Co-Authored-By: Claude --- CHANGELOG.md | 2 +- KNOWN_ISSUES.md | 2 +- vera/slots.py | 36 +++++++++++++++++++++++------------- vera/wasm/context.py | 5 +++-- 4 files changed, 28 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b44d54a..f9224d6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed -- **A user-defined `fn get` / `fn put` is no longer hijacked by an enclosing handler** ([#1284](https://github.com/aallan/vera/issues/1284)). Three sites answered "does this `get` mean the user's declaration or the effect operation?" independently. The checker answers user-fn-first — `_check_call_with_args` looks a bare name up as a function before it looks it up as an operation, so a declaration named `get` owns every bare `get(...)` in its scope, which an arity or argument-type error at such a call site proves by reporting the *user's* signature (E201/E202). Codegen answered twice more: the declared-effect row in `vera/codegen/functions.py` withheld the intrinsic when `_fn_sigs` already owned the name, and the handler expression in `vera/wasm/calls_handlers.py` installed `get`/`put` unconditionally. From `vera check`-green source that produced, depending on the nesting shape, a **silently wrong value** (`nat_to_int(get(3))` under `handle[State](@Int = 5)` returned the cell's 5 for the function's 4, and the argument was not even emitted), a **module WASM validation rejects** (a `@Bool`-returning user `get` took `state_get_Int`'s i64 into an `i32` position; different-family nesting took the *enclosing* cell's getter at the wrong width), or a **spurious `[E602]`** in which the #1233 unaddressable-cell gate refused `main` outright, naming "a bare or qualified State operation `get`" the program never contained. The repair is one predicate, `vera.slots.bare_call_denotes_user_fn`, stating the checker's rule once and consumed by the bare-call dispatch in `vera/wasm/calls.py` (which now gates the clause-inline registry, the host-cell intrinsics and the addressability gate together), by the three bare-`FnCall` result-type inference sites, and by the monomorphizer's discovery walk — each passing its own name table, so the sites cannot answer differently about the table they share. Gating the *dispatch* rather than the *registries* is what makes it correct rather than merely consistent: the registries record which cell an op name reaches, which is true whatever the program's declarations are called, and withholding an entry answered both questions with one table. That is why the gate-only fix measured during PR #1283's review turned the loud skip into a differently-broken module, and why it also cost the qualified spelling its cell — `State.put(5)` in a function that also declares `fn put` compiled to `call $vera.put` and failed to link, which now lowers to the intrinsic the checker always meant. Discovery's `MonoContext.fn_names` moves to the same lookup-time question, so a `get(())` fixing a generic's type variable under a handler names the clone the rewrite emits. All 256 conformance and example programs emit byte-identical WAT. **The `W002` async-commutativity warning was the same defect in the checker's own file** and is corrected with them: `_collect_expr_effects` asked `lookup_effect_op` before the scoped function lookup — the last op-first consumer — so a user function named after an operation contributed the *operation's* parent effect to the commutativity analysis instead of its own declared row, wrong in both directions. A **pure** `fn get`, in a program containing no `State` at all, drew `async argument performs State effects`; a `fn get` that performs `IO`, under a row naming `Http` first, drew **no warning**, because the walk bound the name to `Http.get`, which is inside the commutative whitelist, and silently withheld the eager-evaluation warning the program is owed. Both are pinned with rename controls — the byte-identical program with the helper called `gett` / `fetch` was correct throughout — beside a control that an unshadowed bare `get(())` under a `State` row still warns, so the fix cannot degenerate into never reporting `State`. The walk's comment claiming it resolves "like the call checker above" is now true. One caveat the predicate does not close: codegen's name table is not scope-accurate, so an import the call site cannot see still answers "user-owned" there — a silent wrong value and a load failure from check-green source, tracked as [#1299](https://github.com/aallan/vera/issues/1299), a property of the table rather than of the rule. +- **A user-defined `fn get` / `fn put` is no longer hijacked by an enclosing handler** ([#1284](https://github.com/aallan/vera/issues/1284)). Three sites answered "does this `get` mean the user's declaration or the effect operation?" independently. The checker answers user-fn-first — `_check_call_with_args` looks a bare name up as a function before it looks it up as an operation, so a declaration named `get` owns every bare `get(...)` in its scope, which an arity or argument-type error at such a call site proves by reporting the *user's* signature (E201/E202). Codegen answered twice more: the declared-effect row in `vera/codegen/functions.py` withheld the intrinsic when `_fn_sigs` already owned the name, and the handler expression in `vera/wasm/calls_handlers.py` installed `get`/`put` unconditionally. From `vera check`-green source that produced, depending on the nesting shape, a **silently wrong value** (`nat_to_int(get(3))` under `handle[State](@Int = 5)` returned the cell's 5 for the function's 4, and the argument was not even emitted), a **module WASM validation rejects** (a `@Bool`-returning user `get` took `state_get_Int`'s i64 into an `i32` position; different-family nesting took the *enclosing* cell's getter at the wrong width), or a **spurious `[E602]`** in which the #1233 unaddressable-cell gate refused `main` outright, naming "a bare or qualified State operation `get`" the program never contained. The repair is one predicate, `vera.slots.bare_call_denotes_user_fn`, stating the checker's rule once and consumed by the bare-call dispatch in `vera/wasm/calls.py` (which now gates the clause-inline registry, the host-cell intrinsics and the addressability gate together), by the three bare-`FnCall` result-type inference sites, and by the monomorphizer's discovery walk — each passing its own name table, so the sites cannot answer differently about the table they share. Gating the *dispatch* rather than the *registries* is what makes it correct rather than merely consistent: the registries record which cell an op name reaches, which is true whatever the program's declarations are called, and withholding an entry answered both questions with one table. That is why the gate-only fix measured during PR #1283's review turned the loud skip into a differently-broken module, and why it also cost the qualified spelling its cell — `State.put(5)` in a function that also declares `fn put` compiled to `call $vera.put` and failed to link, which now lowers to the intrinsic the checker always meant. Discovery's `MonoContext.fn_names` moves to the same lookup-time question, so a `get(())` fixing a generic's type variable under a handler names the clone the rewrite emits. All 256 conformance and example programs emit byte-identical WAT. **The `W002` async-commutativity warning was the same defect in the checker's own file** and is corrected with them: `_collect_expr_effects` asked `lookup_effect_op` before the scoped function lookup — the last op-first consumer — so a user function named after an operation contributed the *operation's* parent effect to the commutativity analysis instead of its own declared row, wrong in both directions. A **pure** `fn get`, in a program containing no `State` at all, drew `async argument performs State effects`; a `fn get` that performs `IO`, under a row naming `Http` first, drew **no warning**, because the walk bound the name to `Http.get`, which is inside the commutative whitelist, and silently withheld the eager-evaluation warning the program is owed. Both are pinned with rename controls — the byte-identical program with the helper called `gett` / `fetch` was correct throughout — beside a control that an unshadowed bare `get(())` under a `State` row still warns, so the fix cannot degenerate into never reporting `State`. The walk's comment claiming it resolves "like the call checker above" is now true. One caveat the predicate does not close: codegen's name table is not scope-accurate, so a name the call site cannot see still answers "user-owned" there — an invisible import by either of two routes, or a `where` helper of a `forall` parent, which unlike a non-generic parent's keeps a bare key. From check-green source that is a silent wrong value, a load failure, or an E-code-less `unknown func` depending on the route, tracked as [#1299](https://github.com/aallan/vera/issues/1299); a property of the table rather than of the rule. - **`new(State)` reads the cell its contract names** ([#1285](https://github.com/aallan/vera/issues/1285)). `old(State)` has been keyed on the resolved cell family since [#1205](https://github.com/aallan/vera/issues/1205)/[#1209](https://github.com/aallan/vera/issues/1209); `new(State)` read the name-keyed `_effect_ops["get"]`, which holds whichever `State` the effect row registered first. Under a single-`State` row the two keyings coincide, which is why the corpus agreed; under a multi-`State` row the two sides of one `ensures` clause read different cells. `effects(, State>)` with `ensures(new(State) == false)` was check-green *and* verify-green, emitted `state_get_Int`'s i64 into the Bool comparison's `i32.eq`, and died at load with wasmtime's raw `type mismatch: expected i32, found i64`. The type mismatch is the symptom rather than the defect: where both cells share a machine width — `State` beside `State` — the module loaded and answered about the other cell, so `ensures(new(State) == old(State))` on a function that writes neither was refuted at runtime on a contract the verifier had discharged. Codegen now carries a family→getter registry populated at the declared-row registration site from the per-family `CellNames` it already computes, and `_translate_new_expr` keys on `_state_effect_family` exactly as `_translate_old_expr` does. A bare `get(())` names no family and so is right to keep reading the name-keyed registry, source-order-first-wins; a contract names one and must not. diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index c58c979d..04f76c68 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -8,7 +8,7 @@ Defects in shipped compiler, runtime, or tooling behaviour — this table matche | Bug | Issue | |-----|-------| -| Codegen's `_known_fns` mirrors the flat `_fn_sigs`, which keys an import the call site **cannot see** under its bare name, so the #1284 ownership predicate answers "user-owned" at a site where the checker resolved the effect operation — one rule, two tables that disagree. Two routes: a `private fn get` in an imported module (still reachable, so still emitted) and a `public fn get` a selective import excludes. From check-green source: where both cells are i64 the module loads and returns the helper's value instead of the cell's (**silent wrong value**), and where the widths differ it fails to load (`type mismatch: expected i64, found i32`). The checker's answer is nailed by a type oracle rather than asserted — the invisible `get` returns `@Bool` while the caller returns `@Int` from `get(())` and checks green, which is only possible if the checker typed the call from the `State` cell. Pre-existing, not introduced by the #1284 fix, which made the rule one function without making either table scope-accurate. Fix direction: `_known_fns` becomes the names visible at the compiling declaration's module scope, or the flattening stops keying non-visible imports bare — the `mod$…` renaming `_resolve_module_call_wasm_name` already applies to shadowed generics is the precedent. Same module-namespace table family as [#1281](https://github.com/aallan/vera/issues/1281). Spec §7.4 states the language rule ("declarations first — a property of the call site's scope alone"); this row tracks codegen's violation of it. | [#1299](https://github.com/aallan/vera/issues/1299) | +| Codegen's `_known_fns` mirrors the flat `_fn_sigs`, which keys a name the call site **cannot see** under its bare name, so the #1284 ownership predicate answers "user-owned" at a site where the checker resolved the effect operation — one rule, two tables that disagree. Three demonstrated routes: a `private fn get` in an imported module (still reachable, so still emitted); a `public fn get` a selective import excludes; and a `where` helper of a **`forall` parent**, which keeps a bare `_fn_sigs` key beside its clone-qualified one (`helper`/`id` in `ch09_generic_where_helper.vera`) where a non-generic parent's helper does not. All three are check-green, and how the failure lands depends on the route and on the widths: the two import routes are the dangerous ones — where both cells are i64 the module loads and returns the invisible helper's value instead of the cell's (**silent wrong value**), and where the widths differ it fails to load (`type mismatch: expected i64, found i32`). The generic-`where` route is always **loud**: the bare key exists in the signature table while no bare symbol is emitted (the helper is only ever `holder$Bool$where$get`), so the call fails at WAT assembly with `unknown func: failed to find name $get` and no E-code. The checker's answer is nailed by a type oracle rather than asserted — the invisible `get` returns `@Bool` while the caller returns `@Int` from `get(())` and checks green, which is only possible if the checker typed the call from the `State` cell — and each route carries a rename control that returns the checker's value. Pre-existing, not introduced by the #1284 fix, which made the rule one function without making either table scope-accurate. Fix direction: `_known_fns` becomes the names visible in the compiling declaration's **lexical** scope — module scope is not enough, since a generic's `where` helper *is* in the module and still is not in a sibling's scope — or the flattening stops keying invisible names bare, the `mod$…` renaming `_resolve_module_call_wasm_name` already applies to shadowed generics being the precedent. Same module-namespace table family as [#1281](https://github.com/aallan/vera/issues/1281). Spec §7.4 states the language rule ("declarations first — a property of the call site's scope alone"); this row tracks codegen's violation of it. | [#1299](https://github.com/aallan/vera/issues/1299) | | A postcondition may name a `State` the function's effect row never declares, and `vera check` accepts it: `ensures(new(State) == false)` under `effects(>)` reports OK, then `vera compile` fails with **E699** — the internal-compiler-error diagnostic whose own text says the type checker should have rejected the input, which is exactly the situation. Both forms land there for the same reason (no cell of that family exists, so `old()` finds no snapshot local and, since [#1285](https://github.com/aallan/vera/issues/1285), `new()` finds no getter). Loud and never a wrong answer, so this is diagnostic quality rather than soundness — but it is a check-green program that cannot compile, reported against the compiler instead of against the program, with a bug-report request the user should not act on. Before #1285 the `new()` side was worse than loud: the name-keyed lookup found the row's other getter and silently read the wrong cell. Fix direction: validate an `OldExpr`/`NewExpr`'s effect reference against the declared row where the checker already validates the rest of the clause, one rule for both forms; `test_a_family_the_row_does_not_declare_is_loud_on_both_sides` pins today's E699 and is the test to flip. | [#1298](https://github.com/aallan/vera/issues/1298) | | Spec §1.4 says its reserved keywords MUST NOT be used as function names, and for **seventeen of them nothing enforces it**: `then`, `else`, `data`, `type`, `module`, `import`, `public`, `private`, `requires`, `ensures`, `invariant`, `decreases`, `effect`, `with`, `in`, `where` and `pure` all declare and pass `vera check` as `public fn (@Int -> @Int)`. (`handle` is an eighteenth acceptance and is the sanctioned host-entry-point carve-out, §5.2; the other eleven names — `resume`, `exists`, `forall`, `match`, `assert`, `assume`, `fn`, `let`, `if`, `true`, `false` — are refused **E153**.) They are not even traps: `private fn with(@Int -> @Int)` and its `where` / `type` / `pure` / `requires` / `import` siblings are **callable** from the same file, so the declaration resolves and runs like any other function. The comment above `_KEYWORD_FN_NAMES` in `vera/checker/registration.py` gives the opposite as the reason those names are absent from the set — that the contextual lexer "does not admit them as a function name, so no declaration reaches this checker at all" — so the omission rests on a premise the tree refutes, and neither the parser nor the checker holds the MUST. Two resolutions, and the choice is a ruling rather than a mechanical fix: extend `_KEYWORD_FN_NAMES` so the seventeen join `resume` under E153, or narrow §1.4's MUST to the names actually reserved. The `resume` precedent leans to enforcing — `resume` is likewise not a keyword token anywhere and likewise parses, and was reserved *at the checker* precisely because the parser had no keyword to refuse it with — and E153's own rationale, that a keyword names one construct and nothing else (DESIGN principle 3), does not depend on the name being a trap. Until the ruling lands, §1.4's `handle` carve-out sentence deliberately makes no enforcement claim, so the spec does not assert a MUST it cannot back. | [#1296](https://github.com/aallan/vera/issues/1296) | | The browser runtime's `md_render` breaks the round-trip property spec §9.7.6 states for it — `md_parse(md_render(b)) == Ok(b)` does not hold for its output — and with it §12.9.3's identical-results requirement, which names Markdown explicitly. The scope is **any multi-line paragraph**, not the list lazy continuation first observed: the browser preserves a paragraph's internal soft line breaks and does not re-apply the container prefix (`> `, list-item indent) on output, where the native renderer collapses them to spaces. The browser render is also **not stable** — re-rendering its own output moves content out of its container, so `> a b` renders as two lines and then re-renders with the second line no longer quoted — and on a blockquote wrapping a heading and a fenced block it destroys the document: the fence fragments into three and its contents escape the quote, past recovery by any subsequent parse. The native renderer is a fixed point on every case. Parsing is unaffected — `md_has_heading`, `md_has_code_block` and `md_extract_code_blocks` are byte-identical across runtimes on the same inputs — so the defect is scoped precisely to the renderer in `vera/browser/runtime.mjs`. `tests/test_browser.py` pins both runtimes' exact current strings, including the destructive blockquote case, so a fix on either side goes red until the pins are updated deliberately. | [#1294](https://github.com/aallan/vera/issues/1294) | diff --git a/vera/slots.py b/vera/slots.py index e1e0014d..b6cc4f41 100644 --- a/vera/slots.py +++ b/vera/slots.py @@ -97,20 +97,30 @@ def bare_call_denotes_user_fn( supplies the table it actually resolves against — the checker a lexical scope walk, codegen its flat signature keys. One rule over two tables is one answer only where the tables agree, and they do not everywhere: - codegen's ``_fn_sigs`` keys an import the call site cannot see under its - BARE name, so a ``private fn get`` in an imported module — or a public - one a selective import excludes — makes this predicate answer - "user-owned" at a site where the checker resolved the operation. That - residual is a live defect, tracked as #1299 with a demonstrated silent - wrong value and a demonstrated load failure, and it is a property of the - TABLE rather than of the rule; the fix belongs to ``_known_fns``, which - has to become the names visible at the compiling declaration's module - scope. A ``where`` helper is NOT such a case, though the flat keying - invites the assumption: #1015 and the non-generic hoist move every + codegen's ``_fn_sigs`` keys a name the call site cannot SEE under its + BARE name, so this predicate answers "user-owned" where the checker + resolved the operation. Three routes are demonstrated — a ``private fn + get`` in an imported module, a public one a selective import excludes, + and a ``where`` helper of a ``forall`` parent — and all three are one + defect, tracked as #1299. It is a property of the TABLE rather than of + the rule, and the fix belongs to ``_known_fns``, which has to carry the + names visible in the compiling declaration's LEXICAL scope. + + The ``where``-helper route is the one worth spelling out, because the + other two make it tempting to assume helpers are safe. Under a + NON-generic parent they are: #1015 and the non-generic hoist move the helper out of the bare namespace before registration (a local one is - ``holder$where$get``), and the call sites inside its holder are rewritten - with it, so a helper named after an operation shadows the name in its own - body and nowhere else — which is exactly the checker's lexical answer. + ``holder$where$get``) and rewrite its holder's call sites with it, so it + shadows the name in its own body and nowhere else — the checker's + lexical answer exactly. Under a GENERIC parent the helper keeps a bare + ``_fn_sigs`` key beside its clone-qualified one (measured: ``helper`` + and ``id`` in ``tests/conformance/ch09_generic_where_helper.vera``), so + a sibling's bare ``get(())`` under a ``State`` row is claimed by a + helper it cannot see. That route is always LOUD rather than silently + wrong, and for a reason worth keeping: the bare key exists in the + signature table while no bare SYMBOL is emitted — the helper is only + ever ``holder$Bool$where$get`` — so the call lands on ``unknown func: + failed to find name $get`` at WAT assembly, with no E-code. """ return name in user_fn_names diff --git a/vera/wasm/context.py b/vera/wasm/context.py index c03a8310..1c3b7f5f 100644 --- a/vera/wasm/context.py +++ b/vera/wasm/context.py @@ -680,8 +680,9 @@ def _bare_call_denotes_op(self, name: str) -> bool: Not for the QUALIFIED spelling: ``State.get(())`` names the effect, so no declaration can shadow it and the registries answer directly. - The ``_fn_sigs`` mirror is not scope-accurate — it keys an import the - call site cannot see under its bare name, so this can answer + The ``_fn_sigs`` mirror is not scope-accurate — it keys a name the + call site cannot see under its bare name (an invisible import, or a + ``where`` helper of a ``forall`` parent), so this can answer "user-owned" where the checker resolved the operation (#1299). The rule is right and the table is wrong; the fix is that table's. """ From 5b05b002e9d4f59ec38e8bf8cc60fb59acebd872 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 01:07:17 +0100 Subject: [PATCH 04/69] Make the assertions mean what they read as MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit round on PR #1300; all four findings addressed. The resolution pin asserted `"expects 1 argument"` against a user `get` of arity 1, and its comment claimed the message body distinguished the two candidates. It does not — the built-in `get(@Unit)` is arity 1 too, so that clause discriminates nothing. (The test was not actually admitting an op-first resolver: measured, the operation path reports E203 "Effect operation 'get'" where the function path reports E201, and the E-code assertion already caught it. The comment was wrong, not the test.) The fixture now takes TWO parameters and is called with three, so all three ways the candidates differ are asserted — code, noun, and arity — and the comment says which is which. Mutation-validated: forcing the checker op-first turns it red on `'E203' == 'E201'`. WAT symbol assertions were prefix tests: `"call $get" in wat` also accepts `call $getx` and `call $get$Int`, the latter being what a monomorphized clone of a same-named generic emits — a false PASS in the direction that matters, since these pin a dispatch target. Replaced with `wat_calls()` in `tests/codegen_helpers.py`, anchored on a name-character boundary and matching the `return_call` spelling too. Applied to every such site in the three files, including the three in `test_new_state_family_1285.py` the review did not enumerate, so the PR's own tests are consistent. The helper is unit-checked against `getx`, `get$Int`, `state_get_Int2` and `state_get_IntBox`. `test_a_false_postcondition_still_traps` passed for ANY `WasmTrapError`, including an `unreachable` from a GC or narrowing guard — which would prove nothing about the postcondition it exists to establish. Now asserts `kind == "contract_violation"` (measured), mutation-validated. `vera/README.md`'s one-line description of the predicate still said `get`/`put` after `throw_installed` joined the consumers, the same completeness gap already fixed in the two docstrings. Test-only apart from that one README line; the compiler is untouched. Co-Authored-By: Claude --- TESTING.md | 6 +- tests/codegen_helpers.py | 25 ++++++++ tests/test_handler_op_ownership_1284.py | 63 ++++++++++++------- .../test_nat_narrowing_return_differential.py | 3 +- tests/test_new_state_family_1285.py | 15 +++-- vera/README.md | 2 +- 6 files changed, 83 insertions(+), 31 deletions(-) diff --git a/TESTING.md b/TESTING.md index ce688821..8a761ef8 100644 --- a/TESTING.md +++ b/TESTING.md @@ -76,8 +76,8 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_byte_literal_joins_1212.py` | 25 | 759 | #1212 — a `@Byte` literal inside a value-position join lowers at the i32 Byte width. `@Byte` is i32 (spec §11) while an int literal defaults to `i64.const`, and the #865 / #1092 coercions each tested for a TOP-LEVEL `IntLit` — which the checker's bidirectional coercion is equally happy to type inside an `if` or `match` branch. Ten write boundaries are parametrized with the literal in a branch (`let`, handler state-init, clause-dispatched `put`, bare `put`, get-clause `resume` — verbatim the form the E602 clause-lowerability skip message recommends — a clause `with` update, a `@Byte` call argument, a generic constructor field at `Box`, a lifted closure's own RETURN whose named twin had been coerced since #865 while the closure path had no such step, and a HETEROGENEOUS join at a `@Byte` return, where the arm the result-type decider reads is already i32 and a sibling is a bare literal — arm ORDER decided which way that one failed, so both orders and both paths are pinned). The module docstring states what that list is and is not: measured coverage, since the checker's single Byte coercion makes the true enumeration "every position propagating a Byte expectation", which nothing enumerates in one place, each a check-green program that failed WASM validation with `type mismatch: expected i32, found i64` before the fix. Every case carries a VALUE oracle (200, distinguishable from every other constant in its fixture) rather than merely asserting the module runs, and a separate test drives the OTHER branch so a fix that marked only the arm the result type is read off would still fail. The controls are the load-bearing half: a plain `@Int` join must stay i64 — pinned on 5,000,000,000, which an i32 store cannot represent, so a spreading mark is a wrong VALUE and not just a validation failure — a Byte join with no literal arm must be untouched, and a Byte-RETURNING literal join must keep its own #865 return coercion. The constructor-field case runs through the real pipeline (checker artifacts threaded), because the #1092 width keys on the checker-recorded target type | | `test_closure_boundary_widths_1255_1256_1269.py` | 52 | 949 | Widths and pointer-ness at a closure or effect boundary — three burndown defects of one seam, each a boundary answering "what is this declared type" from something other than that type. **#1255**: GC pointer-ness was read off the SYNTACTIC head, so `type SmallByte = { @Byte \| ... }` was rooted on the shadow stack at the closure parameter, return and capture and at the two named-function twins. The oracle is a DIFFERENTIAL against the `@Byte` spelling of the same program rather than an absolute push count — these bodies must allocate or no prologue is emitted at all, so they legitimately root their own intermediates — with the base spelling's own count PINNED beside it, because equality alone also holds when both spellings root the scalar, which is the pre-fix state and what a mutation deleting the exclusion outright would produce. A genuine pointer at each of the four boundaries is the control (rooting nothing anywhere satisfies the differential too), and every shape runs under `VERA_EAGER_GC=1` — a collection at each `$alloc`, where removing a load-bearing push reads back as a wrong value rather than as a passing test. The heap-layout invariant the defect was inert behind is executable here: a module with no string pool at all — the exposure the issue named — still starts its heap above the inline scalar range, and shrinking the two constants that create that margin fires the build guard. **#1256**: the `apply_fn` `call_indirect` signature took each parameter's width from the ARGUMENT, so a `@Byte` formal fed a literal registered two incompatible `$closure_sig` types and trapped; asserted by run AND by the emitted signature list, since a value oracle alone would also pass if both sides converged on the wrong shared width. The join spelling, the refined formal, the function-type-alias arm of the formal recovery, a directly-called named twin and an i64 control (pinned above 2^32, which an i32 parameter cannot carry) surround it. **#1269**: `throw`'s payload was not a `@Byte` write boundary, so `throw(5)` into `Exn<{ @Byte \| @Byte.0 < 10 }>` put an `i64.const` under an i32 tag and failed WASM validation at load. Both halves of the width agreement are pinned — a fix that widened the TAG would also run, and would put a Byte cell at eight bytes everywhere else — across the bare, aliased, refined, branch-literal, qualified-`Exn.throw` and thrown-inside-the-handled-body spellings, the last two reaching registration paths the others do not | | `test_nested_handler_clause_ops.py` | 27 | 971 | #1211 — a handler clause body's bare `get`/`put` belongs to the handler's DECLARATION scope, not to the body it refines. Eight nested shapes, each asserted on all three components (checker accepts, verifier discharges clean, compiled program returns the checker-derived value): `put` in a put clause and in a get clause, a bare `get` in a `with` state-update expression, depth-3 nesting proving the IMMEDIATELY enclosing handler wins, the qualified `State.put` spelling, a nested handle expression inside a clause body (its registries must be restored to the declaration's, not the intervening handler's), and the two op-result-type mirrors — a bare `get(())` in match-scrutinee (`_effect_op_result_wt`) and array-element (`_effect_op_result_vera`) position, both of which emitted invalid WASM for a check-green program before the alignment. Every oracle is derived from the checker's story, never from what codegen emits, and a meta-test asserts each shape still SEPARATES enclosing-cell from inner-cell routing (the pre-fix value is recorded per case) so none can go vacuous. Round two adds the two dispositions of an EMPTY enclosing handler stack — the declared effect row (the only route that reads the restored `_effect_ops`, which every handler-enclosed case bypasses) and the outermost handler in a `pure` function (E122 at check) — the enclosing handler's own clause running on the outward-routed op (a transforming `with` one level out: 300100, where the intrinsic reading gives 300050), `IO.print` inside a clause body, the #1233 same-family refusals (nested handler, `with` expression, declared row) with their different-family control, and the outward-re-entry depth cap (below it, at it with a WAT-size bound, and past it as a loud E602) | -| `test_handler_op_ownership_1284.py` | 15 | 398 | #1284 — whose declaration a bare `get`/`put` call site denotes. The checker resolves user-fn-first (pinned directly: an over-applied `get` under a `handle[State]` reports the USER signature's arity), and codegen used to answer that question twice more and differently — the declared-effect row withheld the op when a function owned the name, the handler expression overwrote unconditionally. Four shapes from check-green source, each asserted on the CHECKER's value and on the dispatch target in the emitted WAT: a handled body returning the cell instead of the function's answer (silently 5 for 4), a `@Bool`-returning user `get` whose module WASM validation rejected, same-family nesting refused outright with a spurious `[E602]` naming a State operation the source never contained, and different-family nesting emitting the enclosing cell's getter at the wrong width. A parametrized differential runs all five shapes (the four plus a user `put`) as one table with each case's pre-fix behaviour recorded, so a case that stops distinguishing the two answers is visible rather than vacuous; the controls — an unshadowed handler and an unshadowed declared row, both of which must still reach the intrinsics — are what a fix that simply stopped installing the ops would fail, and `new(State)` under a shadowed op name pins that the #1285 family registry composes with this | -| `test_new_state_family_1285.py` | 9 | 318 | #1285 — which cell `new(State)` reads under a multi-`State` effect row. `old()` has been family-keyed since #1205/#1209 while `new()` read the name-keyed op registry, so the two sides of one `ensures` clause read different cells: `effects(, State>)` with `ensures(new(State) == …)` was check-green and verify-green, put `state_get_Int`'s i64 into the Bool comparison's `i32.eq`, and died at load. Three multi-row cases — the width-mismatched shape that could not load, an `Int`/`Nat` pair that loaded and answered about the wrong cell, and `old()` beside `new()` of one family, whose unchanged-cell claim the runtime refuted on a contract the verifier had discharged — plus the single-`State` and alias-spelled controls the whole existing corpus exercises. Each cell is seeded from a caller's handler at a value the other cell is not holding, so a wrong-cell read cannot coincide with the right answer, and a deliberately false postcondition asserts the Tier 3 runtime check really traps, without which every "the program runs" assertion here would prove nothing | +| `test_handler_op_ownership_1284.py` | 15 | 419 | #1284 — whose declaration a bare `get`/`put` call site denotes. The checker resolves user-fn-first (pinned directly: an over-applied `get` under a `handle[State]` reports the USER signature's arity), and codegen used to answer that question twice more and differently — the declared-effect row withheld the op when a function owned the name, the handler expression overwrote unconditionally. Four shapes from check-green source, each asserted on the CHECKER's value and on the dispatch target in the emitted WAT: a handled body returning the cell instead of the function's answer (silently 5 for 4), a `@Bool`-returning user `get` whose module WASM validation rejected, same-family nesting refused outright with a spurious `[E602]` naming a State operation the source never contained, and different-family nesting emitting the enclosing cell's getter at the wrong width. A parametrized differential runs all five shapes (the four plus a user `put`) as one table with each case's pre-fix behaviour recorded, so a case that stops distinguishing the two answers is visible rather than vacuous; the controls — an unshadowed handler and an unshadowed declared row, both of which must still reach the intrinsics — are what a fix that simply stopped installing the ops would fail, and `new(State)` under a shadowed op name pins that the #1285 family registry composes with this | +| `test_new_state_family_1285.py` | 9 | 323 | #1285 — which cell `new(State)` reads under a multi-`State` effect row. `old()` has been family-keyed since #1205/#1209 while `new()` read the name-keyed op registry, so the two sides of one `ensures` clause read different cells: `effects(, State>)` with `ensures(new(State) == …)` was check-green and verify-green, put `state_get_Int`'s i64 into the Bool comparison's `i32.eq`, and died at load. Three multi-row cases — the width-mismatched shape that could not load, an `Int`/`Nat` pair that loaded and answered about the wrong cell, and `old()` beside `new()` of one family, whose unchanged-cell claim the runtime refuted on a contract the verifier had discharged — plus the single-`State` and alias-spelled controls the whole existing corpus exercises. Each cell is seeded from a caller's handler at a value the other cell is not holding, so a wrong-cell read cannot coincide with the right answer, and a deliberately false postcondition asserts the Tier 3 runtime check really traps, without which every "the program runs" assertion here would prove nothing | | `test_adt_membership_scope_1253.py` | 5 | 334 | #1253 — a checker↔codegen DIFFERENTIAL over one module's slot table. `_adt_layouts` is one map across every absorbed namespace, so a sibling module's ADTs were members of a module that never imported them while the checker kept the name opaque: `['Array', 'Array']` against `['Array', 'Array']` for the same signature. Each case renders the module's parameters through `vera.naming` twice — once against the environment the checker binds that module's declarations in (built by the production `_modules_visible_to` + `check_program` path, not a rebuild of it) and once against codegen's `_alias_env` inside `_module_alias_scope` — and asserts both the agreement and the checker's own value, so an alignment on the WRONG name still fails. Three membership cases (an unimported public sibling, a private sibling, and the imported positive control that is green before and after — what separates scoping the membership from erasing cross-module ADTs) plus the entry program's own view, which must keep seeing the ADT it imports by name | | `test_import_visibility_entry_point_1244.py` | 6 | 269 | #1244 — `vera check` reports the same diagnostics whether it was given a module or a file that imports it. Registration alone says what a module DECLARES; the importer never checked its bodies, so a name a module never imported was rejected standalone (E200, §8.5.1) and accepted in silence through an importer. Written as EQUALITY between the two entry points rather than as "the importer warns", because the property is agreement — a future change making the standalone verdict lenient would satisfy a one-sided assertion and must fail here on the standalone leg. Six cases: the leaked unimported name, the honest control that imports what it uses (green before and after, so the new body check is the visibility rule rather than a blanket rejection of cross-module programs), the issue's type-error-through-importer shape (an `@Int` call bound to a `@Bool` slot: check-clean, Tier-1, failing at compile), a diamond proving each module is reported ONCE (the body check is memoised by path across nested checkers), and both entry points into an import cycle proving the memo terminates it | | `test_clone_body_declaring_module_1241_1243.py` | 5 | 344 | #1241 + #1243 — an imported generic's clone body resolves its bare calls in the DECLARING module, on both sides. The verifier's lexical lookup fell through to the importer's registry (`_declaring_module_scope` swapped the naming env, source and file but not the function registry) and codegen's clone-emission door was the one door that did not thread the module's intra-rename map, so `glib`'s `gen` called the importer's `need`. The two halves are one routing rule, and the tests are written so neither passes alone: each case asserts the `vera verify` verdict AND the `vera run` value together, so the verifier half alone (which makes verify clean while the compiled program still traps on the postcondition it just proved — the measured false Tier-1) fails the same test the codegen half alone (right value, verify still refusing) fails. Every expected value comes from the module verified and run STANDALONE, never from what the importer produces. Shapes: a private direct callee, a two-hop private chain, the type-discriminating pair (`@Int` vs `@Bool` — check-green source that emitted invalid WASM), and the unshadowed-callee control that was correct before and after, which is what pins the defect to the SHADOWED name rather than to cross-module calls in general | @@ -117,7 +117,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_nat_int_widening.py` | 36 | 602 | #813 — `@Nat -> @Int` widening coercion obligation (dual of #552 `nat_bind`, part of the #392 soundness audit): a `@Nat` in (i64.MAX, u64.MAX] reinterprets when widened (u64.MAX → -1), so a `nat_to_int_coerce` obligation that the value is `<= i64.MAX` now fires at the return position — provably-in-range → Tier-1, provably-out-of-range (`@Nat.0 >= 2**63`) → loud E530, unbounded → honest Tier-3 (runtime-guarded), with an `@Int -> @Int` control that must not fire; the unguarded generic-`@Int`-field case also has its `E531` rationale read for WHAT IT SAYS — a value bounded on neither side, not the untranslatable-or-timeout conflation #1251 removed. The #813 follow-up adds the explicit `nat_to_int` built-in and heterogeneous `if`/`match` arms with a non-negative-literal alternative; #820 adds the heterogeneous-`@Int`-slot arm, closure argument, and closure return/capture obligations (each per-arm / per-site, with `@Int`-arm and `@Nat`-formal controls that must not fire) | | `test_int_widening_codegen.py` | 52 | 535 | #813 Stage 3 — runtime `@Nat -> @Int` widening-trap codegen: the codegen emits a guard at *exactly* the `@Nat -> @Int` coercion sites the verifier obligates (return, `let`, call argument, and — since #820 — array element, tuple construction/destructure, heterogeneous `if`/`match` arm, closure argument/return), so `vera run`/`vera compile` programs trap when a `@Nat` above i64.MAX would reinterpret to a negative `@Int` instead of silently returning the wrong value. The trap is a bare `unreachable` (shares `_emit_negative_i64_guard` with the #552 nat-bind guard), classified `kind="unreachable"` today (a dedicated widening trap kind is a follow-up) | | `test_int_widening_differential.py` | 26 | 320 | #813 verifier↔codegen behavioural differential (cross-component soundness rule): at every `@Nat -> @Int` coercion site the verifier's `nat_to_int_coerce` classification must AGREE with the runtime — a `tier3` (codegen-guarded) site MUST trap on a `@Nat` above i64.MAX (return / `let` / call-arg / constructor field / ADT sub-pattern / match-bind, and the #820 array-element / tuple-component / heterogeneous-arm / closure argument-return sites), while a `tier3_unguarded` (E531) site must NOT trap (the generic-instantiated `@Int`-field coercion codegen cannot guard). Runs BOTH sides on one corpus so the "runtime-guarded" claim is checked against the actual trap — catching a verifier deferral codegen never guards (unsound silent -1) or a spurious trap | -| `test_nat_narrowing_return_differential.py` | 136 | 2,892 | #758 verifier↔codegen behavioural differential (cross-component soundness rule): at the function-return `@Int -> @Nat` coercion slot the verifier's `nat_bind` verdict must AGREE with the runtime — an unproven narrowing leaves the return obligation undischarged (loud E503, or an honest `tier3` for an opaque value) and codegen's return guard TRAPS on a negative input, while a proven narrowing (`requires` / path condition) discharges at Tier 1 and the guard is dead (`vera run` returns the value, no trap). Runs BOTH sides on one corpus so "the verifier obligates this return" is checked against the actual guard — the return-position dual of `test_int_widening_differential`. #983 review adds the `tier3` quadrant (opaque `float_to_int`, verify + compile in one run), `let_before_tail` / `nested_if_join` join shapes, a `type Count = Nat` alias case, and threads `file=` + `resolved_modules=` through the verify side for CLI-pipeline fidelity. #1017 adds the `apply_fn` ARGUMENT-narrowing quadrant (the `@Int -> @Nat` dual of the #820 argument widening): a provably-negative arg is E503, a runtime arg is obligated + `call_indirect`-guarded (run traps), a `requires`-bounded arg proves Tier 1, a `@Nat -> @Nat` arg is unobligated, and an opaque `float_to_int` arg records `tier3` with the codegen `i64.lt_s`/`unreachable` guard emitted (verify + compile cross-checked in one pipeline run). #1024 adds the REFINED apply_fn-argument quadrant (`refine_bind`, the refinement dual of #1017): an argument narrowing into a `{@Nat \| @Nat.0 > 0}` closure formal discharges the FULL predicate refined-first — a constant `0` is E505 (clears the `@Nat` base's `>= 0` but violates `> 0`), a runtime arg is obligated + guarded at the lifted closure's prologue (run(0) traps with a `contract_violation` Refinement-violation message), a constant `5` / `requires`-bounded arg proves Tier 1, and a `@Pos -> @Pos` arg is unobligated. #1032 adds the REFINED closure-RETURN quadrant (the return-side dual of #1024): `fn(@Int -> @Pos) { @Int.0 }` records exactly one tier3 `refine_bind` (opaque body — never a false Tier 1), run(-5) AND run(0) trap at the lifted body's return guard with the "return value" refinement message, a satisfying value passes, and the always-satisfying body stays an honest tier3 with no spurious trap — plus the re-derived single-guard pin (exactly one `contract_fail` refinement check in the lifted body, zero `i64.lt_s` narrowing checks). PR #1202 adds the #1203 handler-boundary quadrants (init/put/with/resume × trap/pass/zero, bare-put and clause-body-put dispatch shapes, widen duals at U64_MAX with i64.MAX boundary controls) and the #1205 scalar-alias family quadrants: alias and refined-alias `State` cells compile and run, every #1203 guard keys through the alias (init/put trap on negatives, widen dual at U64_MAX), the alias-equal annotation binds clause slots under its SOURCE name, a stateless handler's clause `@T.0` reaches the op ARGUMENT (the pre-fix capture skew read the cell — pinned in both directions), `Exn` compiles with the payload bound under the clause pattern's name, `old(State)` snapshots through the collapsed family, and the retired lying-annotation fixture is pinned as check-rejected E336. The second adversarial round adds the clause-scope checker-parity battery (mixed-spelling State and Exn shapes bind under the checker's canonicalized argument names, both patternless twins bind nothing, the declaration-scope shadow probe), the parameterised-alias family differentials (`State>`, alias-of-generic-alias, `Exn>`), and the `State` write-boundary battery (init/clause-put/bare-put/`with`/resume literals at i32) | +| `test_nat_narrowing_return_differential.py` | 136 | 2,893 | #758 verifier↔codegen behavioural differential (cross-component soundness rule): at the function-return `@Int -> @Nat` coercion slot the verifier's `nat_bind` verdict must AGREE with the runtime — an unproven narrowing leaves the return obligation undischarged (loud E503, or an honest `tier3` for an opaque value) and codegen's return guard TRAPS on a negative input, while a proven narrowing (`requires` / path condition) discharges at Tier 1 and the guard is dead (`vera run` returns the value, no trap). Runs BOTH sides on one corpus so "the verifier obligates this return" is checked against the actual guard — the return-position dual of `test_int_widening_differential`. #983 review adds the `tier3` quadrant (opaque `float_to_int`, verify + compile in one run), `let_before_tail` / `nested_if_join` join shapes, a `type Count = Nat` alias case, and threads `file=` + `resolved_modules=` through the verify side for CLI-pipeline fidelity. #1017 adds the `apply_fn` ARGUMENT-narrowing quadrant (the `@Int -> @Nat` dual of the #820 argument widening): a provably-negative arg is E503, a runtime arg is obligated + `call_indirect`-guarded (run traps), a `requires`-bounded arg proves Tier 1, a `@Nat -> @Nat` arg is unobligated, and an opaque `float_to_int` arg records `tier3` with the codegen `i64.lt_s`/`unreachable` guard emitted (verify + compile cross-checked in one pipeline run). #1024 adds the REFINED apply_fn-argument quadrant (`refine_bind`, the refinement dual of #1017): an argument narrowing into a `{@Nat \| @Nat.0 > 0}` closure formal discharges the FULL predicate refined-first — a constant `0` is E505 (clears the `@Nat` base's `>= 0` but violates `> 0`), a runtime arg is obligated + guarded at the lifted closure's prologue (run(0) traps with a `contract_violation` Refinement-violation message), a constant `5` / `requires`-bounded arg proves Tier 1, and a `@Pos -> @Pos` arg is unobligated. #1032 adds the REFINED closure-RETURN quadrant (the return-side dual of #1024): `fn(@Int -> @Pos) { @Int.0 }` records exactly one tier3 `refine_bind` (opaque body — never a false Tier 1), run(-5) AND run(0) trap at the lifted body's return guard with the "return value" refinement message, a satisfying value passes, and the always-satisfying body stays an honest tier3 with no spurious trap — plus the re-derived single-guard pin (exactly one `contract_fail` refinement check in the lifted body, zero `i64.lt_s` narrowing checks). PR #1202 adds the #1203 handler-boundary quadrants (init/put/with/resume × trap/pass/zero, bare-put and clause-body-put dispatch shapes, widen duals at U64_MAX with i64.MAX boundary controls) and the #1205 scalar-alias family quadrants: alias and refined-alias `State` cells compile and run, every #1203 guard keys through the alias (init/put trap on negatives, widen dual at U64_MAX), the alias-equal annotation binds clause slots under its SOURCE name, a stateless handler's clause `@T.0` reaches the op ARGUMENT (the pre-fix capture skew read the cell — pinned in both directions), `Exn` compiles with the payload bound under the clause pattern's name, `old(State)` snapshots through the collapsed family, and the retired lying-annotation fixture is pinned as check-rejected E336. The second adversarial round adds the clause-scope checker-parity battery (mixed-spelling State and Exn shapes bind under the checker's canonicalized argument names, both patternless twins bind nothing, the declaration-scope shadow probe), the parameterised-alias family differentials (`State>`, alias-of-generic-alias, `Exn>`), and the `State` write-boundary battery (init/clause-put/bare-put/`with`/resume literals at i32) | | `test_hetero_widen_tailcall.py` | 21 | 312 | The heterogeneous per-arm widen guard vs tail calls (#986) and targets: an arm whose `@Nat` value is a tail call must lower to a plain `call` so the appended guard stays live (`return_call` would skip it — the widening dual of the #983 per-leaf narrowing), the genuine `@Int` arm's recursive `return_call` keeps TCO (100k-depth run), the gate is target-aware (`_is_hetero_int_widen_join`: a hetero join in a `@Nat`-returning context must NOT widen-guard its legal `@Nat` arm — the target-blind gate false-trapped 2^63), and a user `data Tuple` must not take the builtin variadic carrier's target-table path (verifier emits no obligation there; guarding it was an opposite-direction desync) | | `test_xmod_span_collision.py` | 4 | 156 | #987 — the span-keyed target-type table is single-module (keyed by bare span, no file identity): an imported body's expression span can coincide with a main-file entry. #987 threads each module's OWN table into codegen (`CheckArtifacts.module_artifacts` → `_compile_fn(module_tables=...)`), so the engineered line-for-line collision pair now proves the legal all-`@Nat` imported function is not falsely widen-guarded by CORRECTNESS (its own table targets `Tuple`), not merely suppression — with a `thread_modules=False` control pinning the #986 suppression fallback still holds when no module tables are threaded, and a same-file control proving top-level guards unaffected | | `test_xmod_widening_differential.py` | 18 | 293 | #987 verifier↔codegen widening differential run THROUGH THE IMPORT DOOR (the same-file `test_int_widening_differential` was green while this door was open): for each cross-module shape (array-element, tuple-construction, tuple-destructure control, transitive 3-level, and shadowed-import) the library's standalone verify must classify the `@Nat -> @Int` coercion Tier-3, AND the importing program compiled the way `vera run`/`vera compile` compile it (per-module tables threaded) must TRAP at `u64.MAX` — never the silent -1 — while passing `2^63-1` and `42` unchanged. Pins that the #820 array/tuple-construction guards, recovered from the span-keyed target table, now fire for imported bodies. Also pins the import-door trap is the guard's bare `unreachable` net (not some other trap), and a two-independent-libraries-both-widen scenario asserting BOTH imported bodies trap at `u64.MAX` (kills a first-module-only partial-collection mutant) | diff --git a/tests/codegen_helpers.py b/tests/codegen_helpers.py index 09f0e953..7b7a1e75 100644 --- a/tests/codegen_helpers.py +++ b/tests/codegen_helpers.py @@ -38,6 +38,31 @@ _CALL_INDIRECT_RE = re.compile(r"(?m)^\s*call_indirect\b") _TABLE_DECL_RE = re.compile(r"(?m)^\s*\(table\b") +# Characters that may continue a WAT symbol name in emitted Vera output: +# identifiers, the `$` of a mangled clone suffix, and the `.` of a host +# import (`$vera.state_get_Int`). +_WAT_NAME_TAIL = r"(?![0-9A-Za-z_$.])" + + +def wat_calls(wat: str, symbol: str) -> bool: + """Does *wat* contain a call to EXACTLY ``$symbol``? + + A plain ``"call $get" in wat`` is a prefix test, so it also accepts + ``call $getx`` — and, worse, ``call $get$Int``, which is what a + monomorphized clone of a same-named generic would emit. In the + positive direction that is a false PASS: the assertion says "the user's + function was called" while the module called something else whose name + merely starts the same way. Anchoring on a name-character boundary + makes the assertion mean what it reads as. + + Matches the tail-call spelling too: ``return_call $get`` contains + ``call $get``, and both are calls to the same target, so a test that + pins a dispatch target must not go red just because TCO fired. + """ + return re.search( + rf"call \${re.escape(symbol)}{_WAT_NAME_TAIL}", wat, + ) is not None + def exceptions_engine() -> wasmtime.Engine: """A wasmtime engine configured the way ``execute()`` configures its own. diff --git a/tests/test_handler_op_ownership_1284.py b/tests/test_handler_op_ownership_1284.py index 439b431c..6cd54ba0 100644 --- a/tests/test_handler_op_ownership_1284.py +++ b/tests/test_handler_op_ownership_1284.py @@ -31,7 +31,7 @@ import pytest from tests.checker_helpers import _check_ok, _errors -from tests.codegen_helpers import _compile, _run +from tests.codegen_helpers import _compile, _run, wat_calls from tests.verifier_helpers import _verify_ok @@ -61,15 +61,37 @@ # --- the resolution rule, stated at the checker ------------------------ -def test_checker_resolves_bare_get_to_the_user_declaration() -> None: - """E201 against the USER signature — the derivation this fix threads. +# A TWO-argument user `get`, for the resolution pin alone. The built-in +# State `get(@Unit)` is arity ONE, so the two candidates disagree about the +# NUMBER as well as about the code and the noun — see the assertions below. +_USER_GET_ARITY_2 = """ +private fn get(@Nat, @Nat -> @Nat) + requires(true) + ensures(true) + effects(pure) +{ + @Nat.0 + @Nat.1 +} +""" - ``get`` under a ``handle[State]`` whose own ``get`` clause takes - ``@Unit``: passing two arguments reports the *user* function's arity - (1), so the checker has resolved the call to the declaration, not to - the op. This is the fact the two codegen sites must agree with. + +def test_checker_resolves_bare_get_to_the_user_declaration() -> None: + """The derivation this fix threads, pinned on three discriminators. + + An over-applied ``get`` under a ``handle[State]``. The two + candidate resolutions report DIFFERENT diagnostics, and all three ways + they differ are asserted, because any one alone is weak: + + * the CODE — the function path is ``E201``, the operation path + ``E203`` (measured: ``get(1, 2, 3)`` under ``effects(>)`` + with no user declaration reports ``E203``); + * the NOUN — "Function 'get'" against "Effect operation 'get'"; + * the ARITY — 2 against the built-in ``get(@Unit)``'s 1, which is why + this fixture takes two parameters where the rest of the file's takes + one. With a one-argument user ``get`` both candidates report + "expects 1 argument" and the number distinguishes nothing. """ - errs = _errors(_USER_GET + """ + errs = _errors(_USER_GET_ARITY_2 + """ public fn main(@Unit -> @Int) requires(true) ensures(true) @@ -79,15 +101,14 @@ def test_checker_resolves_bare_get_to_the_user_declaration() -> None: get(@Unit) -> { resume(@Int.0) }, put(@Int) -> { resume(()) } } in { - nat_to_int(get(3, 4)) + nat_to_int(get(3, 4, 5)) } } """) assert len(errs) == 1, [d.description for d in errs] - assert errs[0].error_code == "E201" - # The user's arity, not the op's — `get(@Unit)` is arity 1 too, so the - # message body is what distinguishes them. - assert "expects 1 argument" in errs[0].description + assert errs[0].error_code == "E201", errs[0].description + assert "Function 'get'" in errs[0].description, errs[0].description + assert "expects 2 argument" in errs[0].description, errs[0].description # --- shape 1: handled body, scalar result (silently wrong value) ------- @@ -119,8 +140,8 @@ def test_handled_body_user_get_emits_the_user_call() -> None: """The dispatch target, not just the value: `call $get`, no intrinsic.""" result = _compile(_HANDLED_BODY) assert result.wat is not None - assert "call $get" in result.wat - assert "call $vera.state_get_Int" not in result.wat + assert wat_calls(result.wat, "get") + assert not wat_calls(result.wat, "vera.state_get_Int") # --- shape 2: handled body, Bool result (module fails validation) ------ @@ -202,7 +223,7 @@ def test_same_family_nesting_compiles_and_calls_the_user_fn() -> None: assert not [d for d in result.diagnostics if d.error_code == "E602"], ( [d.description for d in result.diagnostics] ) - assert "call $get" in result.wat + assert wat_calls(result.wat, "get") assert _run(_NEST_SAME_FAMILY) == 4 @@ -236,7 +257,7 @@ def test_different_family_nesting_loads_and_calls_the_user_fn() -> None: _check_ok(_NEST_DIFF_FAMILY) result = _compile(_NEST_DIFF_FAMILY) assert result.wat is not None - assert "call $get" in result.wat + assert wat_calls(result.wat, "get") assert _run(_NEST_DIFF_FAMILY) == 4 @@ -265,7 +286,7 @@ def test_user_put_is_not_hijacked_by_the_handler() -> None: _check_ok(_USER_PUT_BODY) result = _compile(_USER_PUT_BODY) assert result.wat is not None - assert "call $put" in result.wat + assert wat_calls(result.wat, "put") assert _run(_USER_PUT_BODY) == 6 @@ -332,8 +353,8 @@ def test_unshadowed_bare_ops_still_route_to_the_intrinsics() -> None: the name, `get`/`put` are the handler's ops exactly as before.""" result = _compile(_UNSHADOWED) assert result.wat is not None - assert "call $vera.state_get_Int" in result.wat - assert "call $vera.state_put_Int" in result.wat + assert wat_calls(result.wat, "vera.state_get_Int") + assert wat_calls(result.wat, "vera.state_put_Int") assert _run(_UNSHADOWED) == 7 @@ -394,5 +415,5 @@ def test_checker_and_codegen_agree_on_every_shadowed_shape( _check_ok(source) result = _compile(source) assert "main" in result.exports, pre_fix - assert f"call ${shadowed}" in result.wat, pre_fix + assert wat_calls(result.wat, shadowed), pre_fix assert _run(source) == expected, pre_fix diff --git a/tests/test_nat_narrowing_return_differential.py b/tests/test_nat_narrowing_return_differential.py index 3891fa4d..0641c728 100644 --- a/tests/test_nat_narrowing_return_differential.py +++ b/tests/test_nat_narrowing_return_differential.py @@ -33,6 +33,7 @@ import pytest +from tests.codegen_helpers import wat_calls from vera.codegen.api import WasmTrapError from collections.abc import Iterator @@ -2761,7 +2762,7 @@ def test_qualified_put_reaches_the_cell_past_a_user_shadow(self) -> None: expr_semantic_types=arts.expr_semantic_types, ) assert result.ok, [d.description for d in result.diagnostics] - assert "call $vera.state_put_Int" in result.wat + assert wat_calls(result.wat, "vera.state_put_Int") # The user's own `put` is still emitted and still callable — it # is simply not what the qualified site denotes. assert "(func $put " in result.wat diff --git a/tests/test_new_state_family_1285.py b/tests/test_new_state_family_1285.py index 08b68992..b14971eb 100644 --- a/tests/test_new_state_family_1285.py +++ b/tests/test_new_state_family_1285.py @@ -31,7 +31,7 @@ import pytest from tests.checker_helpers import _check_ok -from tests.codegen_helpers import _compile, _run +from tests.codegen_helpers import _compile, _run, wat_calls from tests.verifier_helpers import _verify_ok @@ -78,7 +78,7 @@ def test_new_emits_the_named_familys_getter() -> None: """The dispatch target itself, so a case that happened to agree on values still fails when the wrong import is called.""" result = _compile(_BOOL_SECOND) - assert "call $vera.state_get_Bool" in result.wat + assert wat_calls(result.wat, "vera.state_get_Bool") # --- the same defect where BOTH cells are i64 (loads, wrong answer) ---- @@ -126,7 +126,7 @@ def test_new_same_width_cells_read_the_named_one() -> None: _verify_ok(_NAT_SECOND) assert _run(_NAT_SECOND) == 7 result = _compile(_NAT_SECOND) - assert "call $vera.state_get_Nat" in result.wat + assert wat_calls(result.wat, "vera.state_get_Nat") # --- old() and new() on the two sides of one clause ------------------- @@ -202,7 +202,7 @@ def test_single_state_new_is_unchanged() -> None: _verify_ok(_SINGLE) assert _run(_SINGLE) == 7 result = _compile(_SINGLE) - assert "call $vera.state_get_Int" in result.wat + assert wat_calls(result.wat, "vera.state_get_Int") _SINGLE_ALIAS = """ @@ -314,5 +314,10 @@ def test_a_false_postcondition_still_traps() -> None: from vera.codegen.api import WasmTrapError _check_ok(_REFUTED) - with pytest.raises(WasmTrapError): + with pytest.raises(WasmTrapError) as excinfo: _run(_REFUTED) + # The KIND, not merely that something trapped: an `unreachable` from a + # GC guard or a narrowing check would satisfy a bare `raises` and prove + # nothing about the postcondition, which is the one thing this test + # exists to establish. + assert excinfo.value.kind == "contract_violation", excinfo.value.kind diff --git a/vera/README.md b/vera/README.md index 879682a0..c66106c3 100644 --- a/vera/README.md +++ b/vera/README.md @@ -723,7 +723,7 @@ The WASM type inference system (`inference.py`) must also handle all expression **The environment is the other half of the contract, and getting it wrong fails just as silently.** An `AliasEnv` is module-scoped (spec §8.4.1), so every consumer renders against the env of the module that **declared** the enclosing function, narrowed by that function's `forall` variables (`slots.fn_slot_scope` — they shadow same-named module aliases): codegen through `_module_alias_scope`, the monomorphizer against each clone's origin module, the verifier from its own per-module registration, so an imported callee's contract is rendered in *its* namespace rather than the importer's. Rendering against a neighbouring module's namespace is the same failure as rendering with a different renderer. -Two derivations stay behind in `slots.py`, and both are about a type's **representation** rather than its name: `type_expr_slot_name` (the alias-opaque spelling the WASM width/erasure walks and the structural-`Eq` derivability oracle want) and `family_fallback_name` (the last-resort name for a family whose type expression resolves to none). `slots.py` is otherwise presentation — the tables `--explain-slots`, the LSP, and the verifier read — plus two shared predicates the subsystems consult rather than restate: `fn_slot_scope`, the `forall`-narrowing scope helper the tester and the monomorphizer import so their slot scopes narrow exactly the way the checker's do, and `bare_call_denotes_user_fn` (#1284), which answers whether a bare `get`/`put` call site denotes a user declaration or the effect operation. The second exists for the same reason the first does: the checker resolves user-fn-first, and codegen's two op-registry sites and mono discovery each used to decide it themselves — the declared-effect row withheld the op when a function owned the name, the handler expression did not, and a `fn get` called under a `handle[State]` compiled to the host cell intrinsic the checker had never typed. +Two derivations stay behind in `slots.py`, and both are about a type's **representation** rather than its name: `type_expr_slot_name` (the alias-opaque spelling the WASM width/erasure walks and the structural-`Eq` derivability oracle want) and `family_fallback_name` (the last-resort name for a family whose type expression resolves to none). `slots.py` is otherwise presentation — the tables `--explain-slots`, the LSP, and the verifier read — plus two shared predicates the subsystems consult rather than restate: `fn_slot_scope`, the `forall`-narrowing scope helper the tester and the monomorphizer import so their slot scopes narrow exactly the way the checker's do, and `bare_call_denotes_user_fn` (#1284), which answers whether a bare effect-operation call site denotes a user declaration or the operation — for every such name, the built-in `get`, `put` and `throw` among them. The second exists for the same reason the first does: the checker resolves user-fn-first, and codegen's two op-registry sites and mono discovery each used to decide it themselves — the declared-effect row withheld the op when a function owned the name, the handler expression did not, and a `fn get` called under a `handle[State]` compiled to the host cell intrinsic the checker had never typed. The proof that the two sides agree is a differential, not a unit test: `tests/test_slot_naming_differential.py` instruments the checker's naming entry points, sweeps the whole `.vera` corpus plus a targeted battery, and requires zero divergence between the module's answer and a test-local statement of the rule. From efe74b8eaaecc92be9da5f4d6b34fd2b08bcecf8 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Thu, 13 Aug 2026 22:41:40 +0100 Subject: [PATCH 05/69] Give json_stringify one canonical form, produced by both runtimes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec §12.9.3 requires every non-IO operation to produce identical results in both runtimes, and json_stringify did not: the reference host called json.dumps(value, ensure_ascii=False, allow_nan=False) while the browser called bare JSON.stringify. §9.7.1 now states the resolution — the compact form, `,` and `:` unpadded, members in insertion order, non-ASCII emitted literally, numbers rendered by ECMAScript's Number::toString — and the reference host moves to it. Measuring first showed the gap was wider than the issue's two axes. json.dumps renders floats with repr, hard-wired inside json.encoder and unreachable through any separator setting, and repr disagrees with Number::toString on four independent boundaries: the fractional part of an integral value, the exponential threshold at each end of the range, and the spelling of the exponent — plus negative zero. Fixing only the reported symptom would have left 1e16, 1e-7, 0.000001 and -0.0 diverging, so vera/wasm/json_serde.py gains format_json_number, an implementation of ECMA-262 §6.1.6.1.20 that takes its shortest-round-trip digits from repr and recomputes only their placement. String escaping is still delegated to json.dumps, which already agrees with JSON.stringify byte for byte. The reimplementation is checked differentially against the real JSON.stringify over 2,000 doubles drawn from raw bit patterns, not only against a hand-written boundary table: a table proves the cases its author thought of, and those are the cases the code was written to handle. Five mutations of the placement logic were confirmed to turn it red. The third asymmetry #1293 folds in closes in the other direction: a JNumber holding NaN or an infinity now fails on both hosts with the same sentence, where the browser emitted "null" — swapping a value RFC 8259 cannot carry for a different, perfectly valid one no consumer could tell from a genuine JNull. Tests: the eleven Node-only tag assertions become parity assertions; the two pinned-divergence strings collapse to single-truth ones; new number-boundary, three-pass idempotence and two-sided failure cases (must raise AND print nothing, so a host that emits "null" before failing cannot read as a pass). Three reference-host assertions that tested json_stringify through string_length or `> 0` now pin the exact text, since a length assertion survives every separator and number-format change the canonical form is about. Closes #1293 Co-Authored-By: Claude --- CHANGELOG.md | 3 + FAQ.md | 2 +- README.md | 2 +- ROADMAP.md | 2 +- TESTING.md | 6 +- docs/llms-full.txt | 2 +- spec/09-standard-library.md | 13 +- tests/test_browser.py | 345 ++++++++++++++++++++++++------ tests/test_codegen_collections.py | 8 +- tests/test_codegen_json.py | 172 +++++++++++++-- vera/README.md | 4 +- vera/browser/runtime.mjs | 20 +- vera/runtime/json.py | 21 +- vera/wasm/json_serde.py | 155 ++++++++++++++ 14 files changed, 642 insertions(+), 113 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9224d6e..251a4e8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **`new(State)` reads the cell its contract names** ([#1285](https://github.com/aallan/vera/issues/1285)). `old(State)` has been keyed on the resolved cell family since [#1205](https://github.com/aallan/vera/issues/1205)/[#1209](https://github.com/aallan/vera/issues/1209); `new(State)` read the name-keyed `_effect_ops["get"]`, which holds whichever `State` the effect row registered first. Under a single-`State` row the two keyings coincide, which is why the corpus agreed; under a multi-`State` row the two sides of one `ensures` clause read different cells. `effects(, State>)` with `ensures(new(State) == false)` was check-green *and* verify-green, emitted `state_get_Int`'s i64 into the Bool comparison's `i32.eq`, and died at load with wasmtime's raw `type mismatch: expected i32, found i64`. The type mismatch is the symptom rather than the defect: where both cells share a machine width — `State` beside `State` — the module loaded and answered about the other cell, so `ensures(new(State) == old(State))` on a function that writes neither was refuted at runtime on a contract the verifier had discharged. Codegen now carries a family→getter registry populated at the declared-row registration site from the per-family `CellNames` it already computes, and `_translate_new_expr` keys on `_state_effect_family` exactly as `_translate_old_expr` does. A bare `get(())` names no family and so is right to keep reading the name-keyed registry, source-order-first-wins; a contract names one and must not. +- **`json_stringify` has one canonical output form, and both runtimes produce it** ([#1293](https://github.com/aallan/vera/issues/1293)). The two hosts disagreed: the reference runtime called `json.dumps(value, ensure_ascii=False, allow_nan=False)` — `", "` / `": "` separators, and since `read_json` hands it Python `float`s, a `JNumber` parsed from `1` re-rendered as `1.0` — where the browser called bare `JSON.stringify`. Spec §12.9.3 requires every non-IO operation to produce identical results in both runtimes, so the divergence itself was the defect, and §9.7.1 now states the resolution: the compact form, `,` and `:` with no padding, object members in insertion order, strings escaped with non-ASCII emitted literally, and numbers rendered by ECMAScript's `Number::toString`. The browser already emitted that form; the reference host moved to it. Measuring the gap first showed it was wider than the issue's two axes — `json.dumps` renders floats with `repr`, hard-wired inside `json.encoder` and not reachable through any separator setting, and `repr` disagrees with `Number::toString` on **four** independent boundaries, not one: the fractional part of an integral value (`1.0` vs `1`), the threshold for exponential notation at each end of the range (`1e+16` vs `10000000000000000`, `1e-06` vs `0.000001`), and the spelling of the exponent itself (`1e-07` vs `1e-7`), plus negative zero (`-0.0` vs `0`). Fixing only the reported symptom would have left the other three diverging, so `vera/wasm/json_serde.py` gains `format_json_number`, an implementation of ECMA-262 §6.1.6.1.20 that takes its shortest-round-trip digits from `repr` and recomputes only their placement; string escaping is still delegated to `json.dumps`, which already agrees with `JSON.stringify` byte for byte. The claim "matches ECMAScript" is checked differentially against the real `JSON.stringify` over 2,000 doubles drawn from raw bit patterns, not only against a hand-written boundary table, since a table proves the cases its author thought of and those are the cases the code was written to handle. Alongside the formatting, the third asymmetry the issue folds in is closed in the other direction: a `JNumber` holding `NaN` or an infinity now **fails on both hosts** with the same sentence, where the browser used to emit `null` — swapping a value RFC 8259 cannot carry for a different, perfectly valid one that no consumer could tell from a genuine `JNull`. The eleven Node-only tag assertions in `tests/test_browser.py` become full parity assertions, the two pinned-divergence strings collapse into single-truth ones, and the battery gains the number boundaries, a three-pass idempotence check on every case, and the two-sided failure assertion for non-finite values (it must raise *and* print nothing, so a host that emitted `null` before failing cannot read as a pass). + + ## [0.1.11] - 2026-08-13 ### Added diff --git a/FAQ.md b/FAQ.md index 6b5c0b61..4f0dde3f 100644 --- a/FAQ.md +++ b/FAQ.md @@ -279,7 +279,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 10,537 tests, including a 216-program conformance suite +- 10,648 tests, including a 216-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/README.md b/README.md index b8a9c7d2..3e67e36e 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ cp /path/to/vera/SKILL.md ~/.claude/skills/vera-language/SKILL.md ## Project status -Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 10,537 tests, 95% Python code coverage, 216 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. +Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 10,648 tests, 95% Python code coverage, 216 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. The reference compiler — parser, AST, type checker, contract verifier (Z3), WASM code generator, module system, browser runtime, and runtime contract insertion — is working. The language specification is in draft across [14 chapters](spec/). diff --git a/ROADMAP.md b/ROADMAP.md index eeb5b418..65c1dd43 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,7 +12,7 @@ Ordering derives from the design principles ([DESIGN.md](DESIGN.md)): verificati ## Where we are -10,537 tests, 216 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. +10,648 tests, 216 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. ## Stage 19 — The verification completeness sprint diff --git a/TESTING.md b/TESTING.md index 8a761ef8..7cbd83c2 100644 --- a/TESTING.md +++ b/TESTING.md @@ -6,7 +6,7 @@ This is the single source of truth for Vera's testing infrastructure, coverage d | Metric | Value | |--------|-------| -| **Tests** | 10,537 across 164 files (~152,000 lines of test code; 10,369 passed + 26 stress, 142 skipped) | +| **Tests** | 10,648 across 164 files (~152,000 lines of test code; 10,480 passed + 26 stress, 142 skipped) | | **Compiler code coverage** | 95% Python, 87% JavaScript (CI minimum: 80%) | | **Conformance programs** | 216 programs across 9 spec chapters, validating every language feature | | **Example programs** | 42, all validated through `vera check` + `vera verify` | @@ -155,7 +155,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_codegen_numeric.py` | 86 | 1,104 | Math builtins (#199), numeric type conversions (#208), Float64 predicates + constants (#212), int64-min / float-carry to-string regressions (#475) (#419 split) | | `test_codegen_io.py` | 42 | 821 | IO operations (#135: read\_line, read\_file, write\_file, args, exit, get\_env, sleep, time, stderr), Markdown + Regex host bindings (#419 split) | | `test_codegen_collections.py` | 69 | 1,141 | Map + Set collections (#62), wrapper-handle bit-31 tagging (#578) (#419 split) | -| `test_codegen_json.py` | 59 | 984 | Json collection, typed accessors (#419 split) | +| `test_codegen_json.py` | 116 | 1,112 | Json collection, typed accessors (#419 split) | | `test_codegen_decimal.py` | 57 | 779 | Decimal collection, Decimal monomorphization (#419 split) | | `test_codegen_host_effects.py` | 71 | 1,123 | Html/Http/Inference host effects, provider dispatch, postcondition host-import propagation (#823) (#419 split) | | `test_codegen_nat_guards.py` | 61 | 1,435 | **`@Nat` runtime guards**: subtraction underflow (#520) and binding-site narrowing (#552 let site; #747 tuple-destructure / match-bind / ADT sub-pattern / ctor-field / call-arg sites; #758 per-leaf function-return guards incl. type-alias returns and TCO preservation on mixed-arm tails — `i64.lt_s; unreachable` net, `@Int` targets exempt) (#419 split); #758 `@Int -> @Nat` return-position guard; #983 review adds alias-aware return gates (`type Count = Nat` narrow, `type MyInt = Int` widen), the alias-to-refinement single-guard exclusion, and the per-narrowing-leaf emission that keeps a non-narrowing `@Nat -> @Nat` recursive tail call's `return_call` (TCO) intact. #1256 extends both alias-aware gates to a parameterised alias APPLICATION (`type Ident = T; type Count = Ident;`), which the name-only chase resolved to the bare head `Ident` — so neither gate fired and `f(0 - 5)` returned -5 through the `@Nat` slot, #983's silent negative one spelling over. Each parameterised case carries its unparameterised twin as the oracle (the claim is that the two spellings compile to the SAME guard, which a bare presence assertion would not catch losing), plus the run-trap on the violating value, the pass on a satisfying one, and the refinement-over-application control that pins the `_refinement_guard_parts` conjunct still keeps a refined return single-guarded | @@ -196,7 +196,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_tester_coverage.py` | 49 | 1,395 | Tester coverage gaps: String/Float64/ADT parameter input generation, Bool/Byte parameters, unsatisfiable preconditions, type expression edge cases, FP model-value extraction (NaN/Inf/signed-zero, #797), the #1208 naming pin (the threaded alias environment canonicalizes a type-argument alias), and the #1216 resolution set — an alias-typed parameter is trialed rather than skipped, its alias-spelled `requires` constrains the generated inputs, a refined alias reaches Z3 with its predicate so no trial violates codegen's entry guard, a `forall` variable shadowing a same-named module alias still resolves to an unsupported type variable, and a parameter that resolves to a non-encodable type still skips with that type named; plus the #1229 set — an UNTRANSLATABLE input constraint skips the function naming the blocking conjunct instead of scoring the resulting entry-guard trap as a contract failure, on the reported `string_length` repro, on a mixed clause (only the untranslatable conjunct is named), on a quantified precondition and on a refined parameter's predicate, with a Tier-3 control that must still be tested so a skip taxonomy firing on everything cannot pass | | `test_markdown.py` | 59 | 393 | Markdown parser: block/inline parsing, rendering, round-trips, edge cases | | `test_lsp.py` | 146 | 2470 | LSP transport + coordinate layer (#222 Phase C) and language features (#222 Phase D): parametrized code-point↔UTF-16 goldens incl. astral-plane fixtures and surrogate-pair snapping, Span (1-based, exclusive-end) and SourceLocation (0-based col) → LSP Range conversions, point→token-range widening, DocumentStore open/change/close + index invalidation, an in-process handler-drive test, and one stdio end-to-end round-trip against the real `vera lsp` subprocess (initialize → didOpen → shutdown → exit) pinning serverInfo + textDocumentSync capabilities; plus the Phase D feature suite — parse-error single-diagnostic path, type-error verification short-circuit, tier=3 in E520 diagnostic data, per-function tier Hint synthesis (and its suppression for functions with violated obligations), smallest-enclosing-span hover, De Bruijn slot goto (most-recent-parameter jump, out-of-range None, off-slot None, and the #1208 keying pins: a parameterised reference resolves, an alias-spelled parameter is reachable from a canonically-spelled reference, and a `forall` variable shadowing a module alias lands on the right parameter), and typed-hole completion (inside/after hole, away-from-hole None); plus the Phase E speculativeEdit suite — identical-text all-unchanged, breaking edit surfaces newly_undischarged (violated nat_sub) with canonical state untouched, strengthening edit surfaces newly_discharged, parse/type errors report ok:false, deleted functions report removed, proof_delta purity; plus the Phase F1 proposeEdit suite — the apply gate (clean and strengthening edits apply, breaking and non-compiling edits refuse), force overriding both gates with the delta still reported, wiring against a structural fake server (apply round-trip with exact full-document replacement range, refuse touches no canonical state, unopened-URI clamp sentinel), and full-document-range goldens (trailing-newline virtual line, UTF-16 end column); plus the Phase F2 strengthenContract suite — splice goldens (first-clause-only replacement with byte-identical remainder, ensures variant, unknown-fn None), the call-site audit pin (tightened precondition refused with newly_undischarged call_pre items, canonical state untouched), provable-ensures strengthening applies, and the three splice-target refusal paths (no analysis, unparseable document, unknown function); plus the Phase F3 addEffect suite — transitive-caller closure goldens (diamond in declaration order, leaf, unknown-fn None, recursion appears once), handler bounding (#725: a caller discharging the effect around its only call site drops out of the closure and is not rewritten, while a second unhandled path, a call in a handler clause, a handler for another effect, a handler naming a different instance of the same effect (`handle[State]` against a `State` propagation, which the checker does not discharge — end-to-end that caller must still be rewritten and the candidate must still apply, with the matching `State` propagation against the same fixture as the positive control that this handler key does prune something), and a bare `where`-helper call all keep it in, as does a refinement type argument at either depth (`Exn<{ @Int \| p }>` and `Exn>` render as their bare base type but discharge nothing of `Exn` — also pinned end-to-end), while an unparameterised `handle[IO]` does bound an `IO` propagation, a whitespace-spelled `State< Int >` request still bounds, and nesting bounds in either order (a matching handler inside a foreign one, a foreign one inside a matching one) — plus a key-level pin that `handle[Mod.IO]` keeps its module, and the effect-less query pinned handler-unaware; plus the two boundary pins the review added — a call in the handler's STATE INITIALISER keeps its edge, since the initialiser is evaluated in the enclosing scope before the handler is installed (pinned beside the E125 the checker raises there against a `pure` caller, with the identical call in the handler body clean as the contrast), and an alias-spelled handler does not bound a `State` propagation though the checker discharges it (`handle[State]` with `type MyAlias = Int` — the spelling comparison's under-prune, #1292, with the alias-spelled request as the control that does prune)), effect-row rewrite goldens (pure to singleton set, source-preserving append, already-present None, base-name identity blocking State next to State), diamond propagation applying one multi-site candidate with the bystander untouched, mixed append/replace rows with already-satisfied callers skipped, the fully-satisfied no-op shape, and the two refusal paths; plus the #728 instruction-contract suite — the LSP message carries description, rationale, and the Fix: paragraph (also pinning single E501 emission at the LSP surface), and a bare diagnostic maps to the description alone | -| `test_browser.py` | 173 | 3,858 | Browser parity: Python/wasmtime vs Node.js/JS-runtime output equivalence across IO, State, contracts, Markdown, Regex, and the examples the browser target can execute (two explicit lists in the file, not the whole `examples/` directory — interactive stdin, file IO, `DB` and the non-standalone `modules` example are excluded with their reasons recorded); plus the #349 `runtime.mjs` coverage battery — per-value-type and per-key-type Map variants, per-element-type Set variants, cold `Decimal` branches (exact-zero sign, negative-shift division, `decRoundPlaces` special cases, non-finite storage), `readJson`/`json_stringify` across every Json ADT tag, the Regex/Json `Result.Err` arms, and nested-Markdown walks. Two cases pin browser↔native divergences the coverage work uncovered by asserting each runtime's exact output separately — both are tracked bugs, not deliberate boundaries — `json_stringify` (bare `JSON.stringify` vs `json.dumps`: separator padding and integral-number rendering, [#1293](https://github.com/aallan/vera/issues/1293)) and `md_render` on **any multi-line paragraph**, where the browser keeps the paragraph's internal soft line break and does not re-apply the container prefix, its render is not stable under re-render, and a blockquote wrapping a heading and a fenced block is destroyed outright the second time round ([#1294](https://github.com/aallan/vera/issues/1294)) — so a change on either side goes red instead of being absorbed as an expected failure | +| `test_browser.py` | 227 | 4,075 | Browser parity: Python/wasmtime vs Node.js/JS-runtime output equivalence across IO, State, contracts, Markdown, Regex, and the examples the browser target can execute (two explicit lists in the file, not the whole `examples/` directory — interactive stdin, file IO, `DB` and the non-standalone `modules` example are excluded with their reasons recorded); plus the #349 `runtime.mjs` coverage battery — per-value-type and per-key-type Map variants, per-element-type Set variants, cold `Decimal` branches (exact-zero sign, negative-shift division, `decRoundPlaces` special cases, non-finite storage), `readJson`/`json_stringify` across every Json ADT tag, the Regex/Json `Result.Err` arms, and nested-Markdown walks. Two cases pin browser↔native divergences the coverage work uncovered by asserting each runtime's exact output separately — both are tracked bugs, not deliberate boundaries — `json_stringify` (bare `JSON.stringify` vs `json.dumps`: separator padding and integral-number rendering, [#1293](https://github.com/aallan/vera/issues/1293)) and `md_render` on **any multi-line paragraph**, where the browser keeps the paragraph's internal soft line break and does not re-apply the container prefix, its render is not stable under re-render, and a blockquote wrapping a heading and a fenced block is destroyed outright the second time round ([#1294](https://github.com/aallan/vera/issues/1294)) — so a change on either side goes red instead of being absorbed as an expected failure | | `test_conformance.py` | 1080 | 125 | Parametrized conformance suite: parse, check, verify, run, format idempotency across 216 programs | | `test_prelude.py` | 29 | 585 | Prelude injection: Option/Result/array operation detection, combinator shadowing, type aliases, the reserved namespace every injected alias declaration lives in — checked against the checker's own E154 regex rather than a second spelling of the rule, since an alias the prelude injects outside it is one codegen resolves and the checker leaves opaque (#1184/#1221) — end-to-end compilation | | `test_checker_apply_fn.py` | 18 | 455 | #854 — `apply_fn` as a checker special form: zero-warning pins (API + CLI `--json` + closures.vera), E201 arity / E202 type / non-function-first-arg errors, E122/E125 effect-row enforcement for applied fn values, E151 redefinition rejection, variadic two-param application, prelude combinator regression pins | diff --git a/docs/llms-full.txt b/docs/llms-full.txt index cef97513..de3d2649 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -3256,7 +3256,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 10,537 tests, including a 216-program conformance suite +- 10,648 tests, including a 216-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/spec/09-standard-library.md b/spec/09-standard-library.md index c2617fb1..a2e373b7 100644 --- a/spec/09-standard-library.md +++ b/spec/09-standard-library.md @@ -2076,7 +2076,18 @@ The `Json` type is provided by the standard prelude — no explicit `data` decla | Function | Signature | Description | |----------|-----------|-------------| | `json_parse(s)` | `(String) → Result` | Parse a JSON string; `Err` on invalid input | -| `json_stringify(j)` | `(Json) → String` | Serialize a Json value to a JSON string | +| `json_stringify(j)` | `(Json) → String` | Serialize a Json value to its canonical JSON string | + +**Canonical serialization.** `json_stringify` has exactly one output form, per the one-canonical-form principle (§0.2.3), and every runtime produces it byte for byte (§12.9.3): + +- **Separators.** `,` between elements and members, `:` between a key and its value, with no surrounding whitespace. No indentation, no trailing newline. +- **Object members.** Emitted in the insertion order of the underlying `Map`, not sorted. +- **Strings.** Escaped per RFC 8259, with non-ASCII characters emitted literally rather than as `\uXXXX` escapes. +- **Numbers.** Rendered by ECMAScript's `Number::toString` with radix 10 ([ECMA-262 §6.1.6.1.20](https://tc39.es/ecma262/#sec-numeric-types-number-tostring)). A `JNumber` wraps a `Float64`, so this is the rendering rule that matters most: an integral value carries no fractional part (`1`, not `1.0`), the notation switches to exponential at and above `1e21` and below `1e-6`, the exponent is signed and unpadded (`1e-7`, not `1e-07`), and negative zero renders `0`. + +The number rule is what makes serialization non-destructive: a document containing `1` re-serializes as `1`. A form that wrote `1.0` would silently alter integral values in transit, which §0.2.2 (explicitness, no implicit behaviour) rules out. + +`NaN` and infinities have no JSON representation. `json_stringify` on a `JNumber` holding one **fails** rather than substituting `null` — substituting would turn a value the format cannot carry into a different, valid one that no consumer could distinguish from a genuine `JNull`. Guard with `float_is_nan` / `float_is_infinite` (§9.6.12) before serializing. **Object access:** diff --git a/tests/test_browser.py b/tests/test_browser.py index deb30983..54ce39d3 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -14,7 +14,9 @@ import json import math +import random import shutil +import struct import subprocess from pathlib import Path from typing import Any @@ -27,6 +29,7 @@ from vera.parser import parse_file from vera.resolver import ModuleResolver from vera.transform import transform +from vera.wasm.json_serde import format_json_number # --------------------------------------------------------------------------- # Paths @@ -209,6 +212,43 @@ def _both_stdouts(src: str, tmp_path: Path, name: str = "parity") -> tuple[str, return str(py_out), str(node["stdout"]) +def _both_failures(src: str, tmp_path: Path, name: str) -> tuple[str, str]: + """Compile ``src`` ONCE, run the same ``.wasm`` under both runtimes + expecting each to FAIL, and return ``(native_message, browser_message)``. + + The failure-side counterpart to :func:`_both_stdouts`, for operations + whose contract is "refuse loudly" rather than "return a value". Each + side asserts that the call did not succeed, so a host that quietly + produced output — which is exactly what the browser did with a NaN + ``JNumber`` before #1293 — fails here rather than reading as a pass. + """ + src_path = tmp_path / f"{name}.vera" + src_path.write_text(src, encoding="utf-8") + wasm_path, result = _compile_file(src_path, tmp_path) + + try: + native_out = _run_python(result) + except Exception as exc: # noqa: BLE001 — any failure is the signal + native_msg = str(exc) + else: + raise AssertionError( + "native runtime did not fail; " + f"stdout={native_out.stdout!r}" + ) + + node = _run_node(wasm_path) + browser_msg = str(node.get("error") or "") + assert browser_msg, ( + "browser runtime did not fail; " + f"stdout={node['stdout']!r}" + ) + assert node["stdout"] == "", ( + "browser runtime produced output on a failing call: " + f"{node['stdout']!r}" + ) + return native_msg, browser_msg + + def _parity_stdout(src: str, tmp_path: Path, name: str = "parity") -> str: """Compile ``src`` ONCE, run it under both runtimes, assert byte-identical stdout, and return the (shared) value. @@ -3137,8 +3177,10 @@ def test_db_execute_takes_err_arm_in_browser(self, tmp_path: Path) -> None: ("bool", "Bool", "true", "false"), ] -# (id, JSON input as written in Vera source, expected browser +# (id, JSON input as written in Vera source, expected canonical # json_stringify output). One case per Json ADT tag, plus nesting. +# Since #1293 the expected string is the *shared* output of both hosts, +# not the browser's alone: spec §9.7.1 names the compact form canonical. _JSON_TAG_CASES = [ ("jnull", "null", "null"), ("jbool_true", "true", "true"), @@ -3157,6 +3199,46 @@ def test_db_execute_takes_err_arm_in_browser(self, tmp_path: Path) -> None: ("empty_object", "{}", "{}"), ] +# (id, JSON input as written in Vera source, expected canonical output). +# Number rendering is where the two hosts diverged most widely (#1293): +# the integral ``1`` → ``1.0`` mutation the issue names is one row of a +# larger table, because Python's ``repr`` and ECMAScript's +# Number::toString disagree on *four* independent boundaries. Every row +# here was measured on both hosts before the fix and is a boundary, not a +# sample: the exponential thresholds (10^21 upward, 10^-7 downward), the +# exponent's own spelling, and negative zero. +_JSON_NUMBER_CASES = [ + # Integral values render without a fractional part — the #1293 axis. + ("integral", "[1,2]", "[1,2]"), + ("integral_negative", "[-1]", "[-1]"), + ("integral_zero", "[0]", "[0]"), + # Negative zero keeps its sign bit through the ADT but renders "0". + ("negative_zero", "[-0.0]", "[0]"), + # Fractional values are untouched by the integral rule. + ("fractional", "[1.5,2.25]", "[1.5,2.25]"), + ("fractional_small", "[0.1]", "[0.1]"), + ("fractional_long", "[12345.6789]", "[12345.6789]"), + # Upper exponential boundary: plain digits below 10^21, exponent at + # and above it. Python's repr switches at 10^16 instead. + ("plain_1e15", "[1e15]", "[1000000000000000]"), + ("plain_1e16", "[1e16]", "[10000000000000000]"), + ("plain_1e20", "[1e20]", "[100000000000000000000]"), + ("exp_1e21", "[1e21]", "[1e+21]"), + ("exp_1e30", "[1e30]", "[1e+30]"), + ("plain_17_digits", "[123456789012345680]", "[123456789012345680]"), + # Lower exponential boundary: plain digits down to 10^-6, exponent + # below it. Python's repr switches at 10^-5 instead. + ("plain_1e_minus_6", "[0.000001]", "[0.000001]"), + ("exp_1e_minus_7", "[1e-7]", "[1e-7]"), + ("exp_1e_minus_300", "[1e-300]", "[1e-300]"), + # Exponent spelling: no zero padding, explicit sign only when + # positive... which is exactly where Python writes "1e-07". + ("exp_multi_digit_mantissa", "[1.25e-9]", "[1.25e-9]"), + ("exp_max_double", "[1.7976931348623157e308]", + "[1.7976931348623157e+308]"), + ("exp_min_subnormal", "[5e-324]", "[5e-324]"), +] + class TestBrowserMapValueTypes349: """Per-value-type and per-key-type Map host-import parity (#349). @@ -3425,6 +3507,22 @@ def test_from_float_exponential_and_non_finite( """ +def _json_round_trip_src(json_text: str, *, times: int = 1) -> str: + """A ``main`` that parses ``json_text`` and stringifies it ``times`` + times, re-parsing between each — the observable form of the + ``json_stringify ∘ json_parse`` idempotence property.""" + inner = 'round_trip("' + json_text + '")' + for _ in range(times - 1): + inner = f"round_trip({inner})" + return _JSON_ROUND_TRIP_PRELUDE + f""" +public fn main(@Unit -> @Unit) + requires(true) ensures(true) effects() +{{ + IO.print({inner}) +}} +""" + + class TestBrowserJsonRoundTrip349: """``readJson`` / ``json_stringify`` coverage (#349). @@ -3433,25 +3531,13 @@ class TestBrowserJsonRoundTrip349: ``readJson`` — all six ADT tags including the ``decodeMap``-backed JObject arm — never ran in the browser. - These are Node-only assertions rather than parity assertions - because ``json_stringify`` genuinely diverges between the two hosts; - see :meth:`TestBrowserJsonStringifyParity349.test_number_and_spacing` - for the pinned divergence. Pinning the browser side still catches a - regression in ``readJson``'s tag decoding, which is what was - uncovered. + These were Node-only assertions while ``json_stringify`` diverged + between the hosts (#1293). Since that closed they are full parity + assertions: the same ``.wasm`` runs under both runtimes and one + expected string covers both, so a regression in either host's + serializer — not just ``readJson``'s tag decoding — fails here. """ - @staticmethod - def _node_stdout(src: str, tmp_path: Path, name: str) -> str: - src_path = tmp_path / f"{name}.vera" - src_path.write_text(src, encoding="utf-8") - wasm_path, _ = _compile_file(src_path, tmp_path) - node = _run_node(wasm_path) - assert not node.get("error"), ( - f"Node harness reported error: {node.get('error')!r}" - ) - return str(node["stdout"]) - @pytest.mark.parametrize( ("case_id", "json_text", "expected"), _JSON_TAG_CASES, @@ -3460,61 +3546,192 @@ def _node_stdout(src: str, tmp_path: Path, name: str) -> str: def test_json_stringify_tag_round_trip( self, case_id: str, json_text: str, expected: str, tmp_path: Path, ) -> None: - src = _JSON_ROUND_TRIP_PRELUDE + f""" -public fn main(@Unit -> @Unit) - requires(true) ensures(true) effects() -{{ - IO.print(round_trip("{json_text}")) -}} -""" - assert self._node_stdout(src, tmp_path, f"json_{case_id}") == expected + src = _json_round_trip_src(json_text) + assert _parity_stdout(src, tmp_path, f"json_{case_id}") == expected class TestBrowserJsonStringifyParity349: - """``json_stringify`` does NOT match the native runtime (#349 finding, - tracked as #1293). - - The Python host calls ``json.dumps(value, ensure_ascii=False, - allow_nan=False)`` — default ``", "`` / ``": "`` separators, and - ``read_json`` hands it Python ``float``\\ s so an integral JNumber - renders as ``1.0``. The browser host calls bare - ``JSON.stringify(value)`` — no separator padding, and JS renders an - integral ``Number`` as ``1``. - - Every other Json binding is byte-identical across the two runtimes; - this one is not, and nothing in the suite noticed because - ``json_stringify`` had no browser-side caller until #349 added one. - - Both sides are pinned as exact strings rather than marked ``xfail``: - a bare ``xfail`` accepts *any* failure, so a broken compile, a dead - Node harness or an unrelated ``runtime.mjs`` regression would all - read as "yes, the known divergence" and this — the only browser-side - coverage of ``json_stringify`` — would stay green through a real - regression. Pinning both outputs tolerates exactly the documented - difference and nothing else. - - Converging the two hosts therefore fails **five** browser assertions, - not one: this test, plus the ``jarray``, ``jobject``, ``nested`` and - ``array_of_objects`` entries of :data:`_JSON_TAG_CASES` — the four - whose pinned strings carry a separator or an integral number. (The - other seven tag cases are already byte-identical across the hosts.) - That red is the prompt to collapse each pinned pair into a single - parity assertion. + """``json_stringify`` agrees byte for byte across the two runtimes + (#349 finding, tracked as #1293, closed by the canonical form). + + Both hosts emit the compact form spec §9.7.1 pins: ``,`` / ``:`` + with no padding, and numbers rendered by ECMAScript's + Number::toString. Before the fix the Python host called + ``json.dumps(value, ensure_ascii=False, allow_nan=False)`` — ``", "`` + / ``": "`` separators, and ``read_json`` hands it Python ``float``\\ s + so an integral JNumber rendered as ``1.0`` — while the browser host + called bare ``JSON.stringify(value)``. + + The class is a parity battery rather than a pair of pinned strings + because there is no longer a divergence to pin. It keeps the shape + that made the pins useful: an exact expected string on every case, so + a broken compile, a dead Node harness or an unrelated ``runtime.mjs`` + regression cannot read as "as expected". """ def test_number_and_spacing(self, tmp_path: Path) -> None: - src = _JSON_ROUND_TRIP_PRELUDE + """ + """The headline #1293 case: integral numbers and separators.""" + src = _json_round_trip_src("[1,2]") + assert _parity_stdout(src, tmp_path, "json_parity") == "[1,2]" + + @pytest.mark.parametrize( + ("case_id", "json_text", "expected"), + _JSON_NUMBER_CASES, + ids=[c[0] for c in _JSON_NUMBER_CASES], + ) + def test_number_rendering_boundaries( + self, case_id: str, json_text: str, expected: str, tmp_path: Path, + ) -> None: + """Every boundary where ``repr(float)`` and Number::toString part + company, not only the integral one #1293's title names. + + Fixing the integral case alone would leave ``1e16``, ``1e-7``, + ``0.000001`` and ``-0.0`` diverging, and a battery built only + around the reported symptom would not have noticed. + """ + src = _json_round_trip_src(json_text) + assert _parity_stdout(src, tmp_path, f"jsonnum_{case_id}") == expected + + @pytest.mark.parametrize( + ("case_id", "json_text", "expected"), + _JSON_TAG_CASES + _JSON_NUMBER_CASES, + ids=[c[0] for c in _JSON_TAG_CASES + _JSON_NUMBER_CASES], + ) + def test_stringify_is_idempotent( + self, case_id: str, json_text: str, expected: str, tmp_path: Path, + ) -> None: + """``json_stringify(json_parse(·))`` is a fixed point on both hosts. + + A canonical form that is not idempotent is not canonical: feeding + one host's output back through the pair must land on the same + bytes, or ``1`` → ``1.0`` → ``1.0`` style drift can still + accumulate across a pipeline. Three passes, so a form that only + stabilises after the first is caught too. + """ + src = _json_round_trip_src(json_text, times=3) + assert _parity_stdout(src, tmp_path, f"jsonidem_{case_id}") == expected + + +class TestBrowserJsonStringifyNonFinite1293: + """A non-finite ``JNumber`` refuses to serialise on BOTH hosts (#1293). + + RFC 8259 has no NaN and no Infinity, so there is no right answer to + return — only a right way to fail. The native host has always + refused; the browser silently emitted ``null``, turning a value the + format cannot carry into a *different, valid* value that no later + consumer can tell from a genuine JSON ``null``. That is the silent + wrong answer DESIGN §Design principles 2 rules out, and it is the + asymmetry #1293 folds in beside the formatting axes. + + The assertion is deliberately two-sided: the call must raise, **and** + nothing may reach stdout. Asserting only "raises" would still pass a + host that printed ``null`` and then failed for some later reason. + """ + + _SRC = """ public fn main(@Unit -> @Unit) requires(true) ensures(true) effects() -{ - IO.print(round_trip("[1,2]")) -} +{{ + IO.print(json_stringify(JNumber({expr}))) +}} """ - native, browser = _both_stdouts(src, tmp_path, "json_parity") - assert native == "[1.0, 2.0]" - # Known divergence, deliberately not fixed on a tests-only branch: - # bare JSON.stringify gives compact separators and integral numbers. - assert browser == "[1,2]" + + @pytest.mark.parametrize( + ("case_id", "expr"), + [ + ("nan", "nan()"), + ("infinity", "infinity()"), + ("negative_infinity", "0.0 - infinity()"), + ], + ) + def test_non_finite_fails_on_both_hosts( + self, case_id: str, expr: str, tmp_path: Path, + ) -> None: + native, browser = _both_failures( + self._SRC.format(expr=expr), tmp_path, f"jsonnf_{case_id}", + ) + # One shared sentence, so a caller reading either host's failure + # learns the same thing. Substring, not equality: wasmtime and + # the Node harness each wrap the host message in their own frame. + needle = "not representable in JSON" + assert needle in native, native + assert needle in browser, browser + + +class TestCanonicalNumberFormatMatchesEcmascript1293: + """``format_json_number`` is differentially checked against the real + ``JSON.stringify``, not against a table someone typed (#1293). + + The reference host now renders numbers itself instead of delegating + to ``json.dumps``, so "matches ECMAScript" became a claim about a + reimplementation. A hand-written boundary table — which + ``TestCanonicalNumberFormat`` in ``tests/test_codegen_json.py`` also + has — only proves the cases its author thought of, and those are the + cases the implementation was written to handle. This runs the two + implementations against each other over a deterministic random + sample of doubles drawn from raw bit patterns, so the inputs are not + ones either side was designed around. + """ + + @staticmethod + def _ecmascript_strings(values: list[float], tmp_path: Path) -> list[str]: + """``JSON.stringify(n)`` for each value, computed by Node.""" + literals = [repr(v) for v in values] + script = tmp_path / "numfmt.mjs" + script.write_text( + "const xs = " + json.dumps(literals) + ";\n" + "console.log(JSON.stringify(" + "xs.map(s => JSON.stringify(Number(s)))));\n", + encoding="utf-8", + ) + proc = subprocess.run( + [NODE or "node", str(script)], + capture_output=True, text=True, encoding="utf-8", + timeout=60, check=True, + ) + return list(json.loads(proc.stdout)) + + def test_random_doubles_match_json_stringify(self, tmp_path: Path) -> None: + # Seeded: a flaky parity test is worse than a smaller sample. + rng = random.Random(20260813) + values: list[float] = [] + while len(values) < 2000: + bits = rng.getrandbits(64) + (candidate,) = struct.unpack(" None: + """The differential above is only evidence if it can go red. + + ``repr`` is what the old ``json.dumps`` path emitted, and it is + the natural wrong answer here, so the check that would have + passed the pre-#1293 implementation is run explicitly and + required to FAIL. Without this, a broken Node invocation or an + empty sample would make the differential vacuously green. + """ + values = [1.0, 1e16, 1e-7, -0.0] + expected = self._ecmascript_strings(values, tmp_path) + assert expected == ["1", "10000000000000000", "1e-7", "0"] + assert [repr(v) for v in values] != expected class TestBrowserHostErrorPaths349: diff --git a/tests/test_codegen_collections.py b/tests/test_codegen_collections.py index 6b807fa6..bae24112 100644 --- a/tests/test_codegen_collections.py +++ b/tests/test_codegen_collections.py @@ -811,12 +811,12 @@ def test_json_round_trip_uses_host_side_mask(self) -> None: IO.print(json_stringify(@Json.0)) } """ - # Exact rendered output. Python's json.dumps default - # separators include a space after the colon, so the - # form is `{"name": "hi"}` (NOT the compact `{"name":"hi"}`). + # Exact rendered output, in the canonical form spec §9.7.1 + # pins: `:` with no padding (#1293 — this read `{"name": "hi"}` + # while the reference host still went through json.dumps). # Without the host-side mask json_stringify would emit # `{}` instead. - assert _run_io(source, fn="main") == '{"name": "hi"}' + assert _run_io(source, fn="main") == '{"name":"hi"}' # --- Unit tests for the _validate_wrap_handle helper --- # diff --git a/tests/test_codegen_json.py b/tests/test_codegen_json.py index bef8faa6..4e48bcee 100644 --- a/tests/test_codegen_json.py +++ b/tests/test_codegen_json.py @@ -4,12 +4,14 @@ """ from __future__ import annotations +import pytest from tests.codegen_helpers import ( _compile_ok, _run, _run_io, ) +from vera.wasm.json_serde import dumps_canonical, format_json_number class TestJsonCollection: @@ -186,13 +188,20 @@ def test_json_stringify_bool(self) -> None: assert _run(source) == 4 def test_json_stringify_number(self) -> None: - """json_stringify(JNumber(42.0)) returns '42.0' (length 4).""" + """json_stringify(JNumber(42.0)) returns '42' — the canonical + form carries no fractional part on an integral value (#1293). + + Asserted as the text rather than as ``string_length``: the old + length-4 assertion could not tell ``42.0`` from ``null``, and + the whole point of the canonical form is *which* four (or two) + characters come out. + """ source = """ -public fn main(-> @Int) - requires(true) ensures(true) effects(pure) -{ string_length(json_stringify(JNumber(42.0))) } +public fn main(@Unit -> @Unit) + requires(true) ensures(true) effects() +{ IO.print(json_stringify(JNumber(42.0))) } """ - assert _run(source) == 4 + assert _run_io(source) == "42" def test_json_get_present(self) -> None: """json_get on JObject with present key returns Some.""" @@ -333,40 +342,45 @@ def test_json_array_get_negative_index(self) -> None: assert _run(source) == 1 def test_json_stringify_object(self) -> None: - """json_stringify(JObject(...)) round-trips through read_json.""" + """json_stringify(JObject(...)) round-trips through read_json. + + The exact text, not ``> 0``: a length-or-presence assertion + survives every separator and number-format change the canonical + form (#1293) is *about*, so it could not tell ``{"k":1}`` from + ``{"k": 1.0}`` — the two strings the two runtimes used to + disagree on. + """ source = ''' -public fn main(-> @Int) - requires(true) ensures(true) effects(pure) +public fn main(@Unit -> @Unit) + requires(true) ensures(true) effects() { let @Json = JObject(map_insert(map_new(), "k", JNumber(1.0))); - string_length(json_stringify(@Json.0)) + IO.print(json_stringify(@Json.0)) } ''' - result = _run(source) - assert result > 0 + assert _run_io(source) == '{"k":1}' def test_json_stringify_array(self) -> None: - """json_stringify(JArray([...])) exercises read_json array path.""" + """json_stringify(JArray([...])) exercises read_json array path, + and pins the element separator (#1293).""" source = """ -public fn main(-> @Int) - requires(true) ensures(true) effects(pure) +public fn main(@Unit -> @Unit) + requires(true) ensures(true) effects() { let @Json = JArray([JNull, JBool(true), JNumber(2.0)]); - string_length(json_stringify(@Json.0)) + IO.print(json_stringify(@Json.0)) } """ - result = _run(source) - assert result > 0 + assert _run_io(source) == "[null,true,2]" def test_json_stringify_string(self) -> None: """json_stringify(JString(...)) exercises read_json string path.""" source = ''' -public fn main(-> @Int) - requires(true) ensures(true) effects(pure) -{ string_length(json_stringify(JString("hello"))) } +public fn main(@Unit -> @Unit) + requires(true) ensures(true) effects() +{ IO.print(json_stringify(JString("hello"))) } ''' - result = _run(source) - assert result > 0 + assert _run_io(source) == '"hello"' def test_json_stringify_bool_false(self) -> None: """json_stringify(JBool(false)) returns 'false' (5 chars).""" @@ -982,3 +996,117 @@ def test_json_parse_does_force_its_host_import(self) -> None: "json_parse IS a host import and its import table entry " "must be present when the function is referenced." ) + + +class TestCanonicalNumberFormat: + """``format_json_number`` — the canonical JSON number form (#1293). + + ECMA-262 §6.1.6.1.20 Number::toString, which is what + ``JSON.stringify`` uses and what spec §9.7.1 now pins for both + runtimes. Each case below is a *boundary* of that algorithm rather + than a sample, and every one of them is a place ``repr(float)`` + disagrees — which is why ``json.dumps`` cannot produce this form no + matter how its separators are configured. + """ + + @pytest.mark.parametrize(("value", "expected"), [ + # Integral values lose the fractional part repr insists on. + (0.0, "0"), + (-0.0, "0"), + (1.0, "1"), + (-1.0, "-1"), + (100.0, "100"), + (2.0, "2"), + # Non-integral values are untouched by that rule. + (3.5, "3.5"), + (-0.5, "-0.5"), + (0.1, "0.1"), + (12345.6789, "12345.6789"), + # Upper boundary: plain digits below 10^21, exponent from 10^21. + (1e15, "1000000000000000"), + (1e16, "10000000000000000"), + (1e20, "100000000000000000000"), + (1e21, "1e+21"), + (1e30, "1e+30"), + (123456789012345680.0, "123456789012345680"), + # Lower boundary: plain digits to 10^-6, exponent below it. + (1e-6, "0.000001"), + (1e-7, "1e-7"), + (1.5e-5, "0.000015"), + (1e-300, "1e-300"), + # Exponent spelling: signed, unpadded, mantissa point after the + # first digit only when there is more than one digit. + (1.25e-9, "1.25e-9"), + (5e-324, "5e-324"), + (1.7976931348623157e308, "1.7976931348623157e+308"), + (-1e21, "-1e+21"), + ]) + def test_boundary(self, value: float, expected: str) -> None: + assert format_json_number(value) == expected + + @pytest.mark.parametrize("value", [ + float("nan"), float("inf"), float("-inf"), + ]) + def test_non_finite_raises(self, value: float) -> None: + """No coercion to ``null``: the value has no JSON form, so the + call fails and says so.""" + with pytest.raises(ValueError, match="not representable in JSON"): + format_json_number(value) + + @pytest.mark.parametrize("value", [ + 0.0, 1.0, -1.0, 3.5, 1e15, 1e16, 1e21, 1e-6, 1e-7, + 1.25e-9, 5e-324, 1.7976931348623157e308, 0.1, 12345.6789, + ]) + def test_reparses_to_the_same_double(self, value: float) -> None: + """The rendering is lossless. + + Shortening ``1.0`` to ``1`` is only safe if the shorter text + still reads back as the identical double — otherwise the + canonical form would be a lossy one. ``repr``-derived digits + make that true by construction; this checks the *placement* + logic did not drop one. + """ + assert float(format_json_number(value)) == value + + +class TestCanonicalDumps: + """``dumps_canonical`` — the whole-document canonical form (#1293).""" + + @pytest.mark.parametrize(("value", "expected"), [ + (None, "null"), + (True, "true"), + (False, "false"), + (1.0, "1"), + ("hi", '"hi"'), + ([], "[]"), + ({}, "{}"), + ([1.0, 2.0], "[1,2]"), + ({"b": 1.0, "a": 2.0}, '{"b":1,"a":2}'), + ({"a": [1.0, {"c": None}]}, '{"a":[1,{"c":null}]}'), + # Separators carry no padding — the other #1293 axis. + ([1.5, 2.25], "[1.5,2.25]"), + # Non-ASCII text is emitted literally, matching JSON.stringify. + ("café", '"café"'), + ("tab\there", '"tab\\there"'), + ]) + def test_shape(self, value: object, expected: str) -> None: + assert dumps_canonical(value) == expected + + def test_object_keys_keep_insertion_order(self) -> None: + """Both hosts' maps preserve insertion order, so the canonical + form does too — sorting here would diverge from the browser.""" + assert dumps_canonical({"z": 1.0, "a": 2.0, "m": 3.0}) == ( + '{"z":1,"a":2,"m":3}' + ) + + def test_rejects_values_outside_read_json_domain(self) -> None: + """A type ``read_json`` cannot produce means the ADT walk went + wrong; a plausible-looking string would hide that.""" + with pytest.raises(TypeError, match="not a Json value"): + dumps_canonical({1, 2}) + + def test_non_finite_inside_a_document_raises(self) -> None: + """The refusal is not just for a bare number — it survives the + recursive walk, so a NaN buried in an object still fails.""" + with pytest.raises(ValueError, match="not representable in JSON"): + dumps_canonical({"a": [1.0, float("nan")]}) diff --git a/vera/README.md b/vera/README.md index c66106c3..fa8c184c 100644 --- a/vera/README.md +++ b/vera/README.md @@ -113,7 +113,7 @@ execute(compile_result, ...) # → run WASM via wasmtime | ` ├ closures.py` | 549 | | Closures, anonymous functions, free variable analysis | | | ` ├ data.py` | 1,510 | | Constructors, match expressions (incl. nested patterns), arrays, indexing | | | ` ├ markdown.py` | 651 | | WASM memory marshalling for MdInline/MdBlock ADTs | | -| ` ├ json_serde.py` | 265 | | WASM memory marshalling for Json ADT | | +| ` ├ json_serde.py` | 420 | | WASM memory marshalling for Json ADT | | | ` └ html_serde.py` | 261 | | WASM memory marshalling for HtmlNode ADT | | | `markdown.py` | 651 | Compile | Python Markdown parser/renderer (§9.7.3 subset) | `parse_markdown()`, `render_markdown()`, `has_heading()`, `has_code_block()`, `extract_code_blocks()` | | `obligations/` | 725 | Verify | Reified proof obligations + warm incremental session (#222 A/B) | `ProofObligation`, `VerificationSession` | @@ -752,7 +752,7 @@ The `ERROR_CODES` dict in `errors.py` maps every code to a short description (15 ## Test Suite -Testing spans a **pytest suite** of 10,537 tests across 164 files — compiler-internals unit tests plus a **conformance suite** (216 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. +Testing spans a **pytest suite** of 10,648 tests across 164 files — compiler-internals unit tests plus a **conformance suite** (216 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. See **[TESTING.md](../TESTING.md)** for the comprehensive testing reference -- test file table, conformance suite details, compiler code coverage, language feature coverage, helper conventions, validation scripts, CI pipeline, and guidelines for adding tests. diff --git a/vera/browser/runtime.mjs b/vera/browser/runtime.mjs index 5db2da28..50f1d963 100644 --- a/vera/browser/runtime.mjs +++ b/vera/browser/runtime.mjs @@ -2626,7 +2626,25 @@ function buildImportObject(module, moduleBytes) { if (needed.has("json_stringify")) { imports.vera.json_stringify = (ptr) => { const value = readJson(ptr); - const text = JSON.stringify(value); + // #1293: RFC 8259 has no NaN and no Infinity, so there is no + // right value to return for one — only a right way to fail. + // Bare JSON.stringify writes "null", swapping a value the format + // cannot carry for a different, perfectly valid one that no later + // consumer can distinguish from a genuine null. The reference + // runtime has always refused; the replacer makes this host refuse + // with the same sentence. The compact form JSON.stringify + // already emits IS the canonical form (spec §9.7.1), so nothing + // else about the call changes. + const text = JSON.stringify(value, (_key, v) => { + if (typeof v === "number" && !Number.isFinite(v)) { + throw new Error( + `json_stringify: ${String(v)} is not representable in JSON — ` + + `RFC 8259 has no NaN or Infinity. Guard with float_is_nan / ` + + `float_is_infinite before serialising.` + ); + } + return v; + }); // JSON.stringify can return undefined for unsupported values // (e.g. bare undefined, symbols, functions). Fall back to "null" // to match the JSON spec and avoid allocString crashing. diff --git a/vera/runtime/json.py b/vera/runtime/json.py index 164ed990..c28a925d 100644 --- a/vera/runtime/json.py +++ b/vera/runtime/json.py @@ -1,4 +1,4 @@ -"""JSON effect host bindings (JSON effect §9.7.5). +"""JSON host bindings (Json built-in type, spec §9.7.1). Extracted verbatim from `execute()` in `vera/codegen/api.py` (#421); the host callbacks call the module-level heap helpers in @@ -29,7 +29,7 @@ def register_json(linker: wasmtime.Linker, ops_used: set[str]) -> None: """Register the requested JSON host functions on `linker`.""" import json as _json - from vera.wasm.json_serde import read_json, write_json + from vera.wasm.json_serde import dumps_canonical, read_json, write_json if "json_parse" in ops_used: def host_json_parse( @@ -73,16 +73,13 @@ def host_json_stringify( caller, ptr, _read_i32, _read_f64, _read_wasm_string, _decode_jobject, ) - # Note: json.dumps rejects NaN/Infinity by default - # (raises ValueError). This matches the JSON spec - # (RFC 8259) which forbids these values. The JS - # runtime's JSON.stringify outputs "null" for them - # instead. Both behaviours are acceptable: Vera's - # JNumber wraps Float64, so users should guard against - # NaN/Infinity before serialising. - text = _json.dumps( - value, ensure_ascii=False, allow_nan=False, - ) + # #1293: one canonical output form, shared with + # ``vera/browser/runtime.mjs`` and stated in spec §9.7.1. + # ``dumps_canonical`` also refuses NaN and Infinity, which + # RFC 8259 cannot represent — the browser used to emit + # ``null`` for them, silently substituting a different and + # perfectly valid JSON value. + text = dumps_canonical(value) return _alloc_string(caller, text) linker.define_func( diff --git a/vera/wasm/json_serde.py b/vera/wasm/json_serde.py index 1da255f8..2f145d48 100644 --- a/vera/wasm/json_serde.py +++ b/vera/wasm/json_serde.py @@ -13,6 +13,15 @@ read_json(caller, ptr, read_i32, read_f64, read_string, decode_jobject) → Any +Text direction (Python → JSON text): + dumps_canonical(value) → str + format_json_number(value) → str + +The text direction is the last mile of the read direction and lives here +for that reason: ``json_stringify`` is ``read_json`` followed by +``dumps_canonical``. Its output form is canonical and shared with the +browser runtime — see the section comment above ``format_json_number``. + Json ADT layouts (from prelude injection → registration.py): JNull tag=0 () total=8 JBool(Bool) tag=1 (4, i32) total=8 @@ -263,3 +272,149 @@ def read_json( stacklevel=2, ) return None # Unknown tag — should not happen + + +# ===================================================================== +# Canonical serialization (#1293) +# ===================================================================== +# +# ``json_stringify`` has exactly one output form, stated in spec §9.7.1 +# and produced identically by both runtimes (§12.9.3). It is the +# compact form: ``,`` and ``:`` with no padding, and numbers rendered by +# ECMAScript's Number::toString. +# +# The reference host used to reach for ``json.dumps``, which cannot +# produce that form: its separators are configurable but its float +# rendering is ``repr``, hard-wired inside ``json.encoder``. ``repr`` +# and Number::toString disagree on four independent boundaries — the +# fractional part of an integral value, the threshold for switching to +# exponential notation at each end of the range, and the spelling of the +# exponent itself — so matching the canonical form means rendering +# numbers here rather than delegating. +# +# String escaping is *not* reimplemented: ``json.dumps(s, +# ensure_ascii=False)`` and ``JSON.stringify(s)`` already agree byte for +# byte over the escape table, so the one function that is right is the +# one that gets called. + +_NON_FINITE_NAMES = {"nan": "NaN", "inf": "Infinity", "-inf": "-Infinity"} + + +def _non_finite_message(name: str) -> str: + """The single sentence both runtimes raise for a non-finite number. + + Kept identical to the string in ``vera/browser/runtime.mjs`` so a + caller reading either host's failure learns the same thing. + """ + return ( + f"json_stringify: {name} is not representable in JSON — RFC 8259 " + f"has no NaN or Infinity. Guard with float_is_nan / " + f"float_is_infinite before serialising." + ) + + +def _shortest_digits(value: float) -> tuple[str, int]: + """Decompose a positive, finite float into ``(digits, n)`` such that + ``value == int(digits) * 10 ** (n - len(digits))``. + + ``digits`` carries no leading or trailing zeros, which makes it the + ``s`` of ECMA-262 §6.1.6.1.20 and ``n`` the position of the decimal + point relative to its first digit. ``repr`` already yields the + shortest decimal that reads back as the same double, so the digits + are taken from it unchanged and only their *placement* is recomputed. + """ + mantissa, _, exponent = repr(value).partition("e") + exp = int(exponent) if exponent else 0 + int_part, _, frac_part = mantissa.partition(".") + digits = int(int_part + frac_part) + e10 = exp - len(frac_part) + while digits >= 10 and digits % 10 == 0: + digits //= 10 + e10 += 1 + text = str(digits) + return text, e10 + len(text) + + +def format_json_number(value: float) -> str: + """Render a JSON number in the canonical form (spec §9.7.1). + + This is ECMA-262 §6.1.6.1.20 Number::toString with radix 10, which + is what ``JSON.stringify`` uses for numbers. Non-finite values have + no JSON representation and raise instead of being coerced. + """ + if value != value or value in (float("inf"), float("-inf")): + raise ValueError(_non_finite_message( + _NON_FINITE_NAMES[repr(value)], + )) + if value == 0.0: + return "0" # covers -0.0: ECMAScript renders both zeros as "0" + if value < 0.0: + return "-" + format_json_number(-value) + + digits, n = _shortest_digits(value) + k = len(digits) + if k <= n <= 21: + # Integral, and short enough to write out: pad with zeros. + return digits + "0" * (n - k) + if 0 < n <= 21: + # Decimal point falls inside the digits. + return digits[:n] + "." + digits[n:] + if -6 < n <= 0: + # Leading zeros, down to but not past 10^-6. + return "0." + "0" * (-n) + digits + # Exponential. The exponent is written with an explicit sign and no + # zero padding, where ``repr`` writes "1e-07". + exp = n - 1 + mantissa = digits if k == 1 else digits[0] + "." + digits[1:] + return f"{mantissa}e{'+' if exp >= 0 else '-'}{abs(exp)}" + + +def dumps_canonical(value: Any) -> str: + """Serialize a value read by :func:`read_json` to canonical JSON text. + + The accepted domain is exactly what :func:`read_json` returns — + ``None``, ``bool``, ``float``, ``str``, ``list``, ``dict`` — and + anything else raises rather than being coerced, because a value + outside that set means the ADT walk went wrong and a plausible-looking + string would hide it. Object keys keep insertion order, which is + what both hosts' underlying maps preserve. + """ + import json as _json + + parts: list[str] = [] + + def emit(node: Any) -> None: + if node is None: + parts.append("null") + elif node is True: + parts.append("true") + elif node is False: + parts.append("false") + elif isinstance(node, float): + parts.append(format_json_number(node)) + elif isinstance(node, str): + parts.append(_json.dumps(node, ensure_ascii=False)) + elif isinstance(node, list): + parts.append("[") + for i, item in enumerate(node): + if i: + parts.append(",") + emit(item) + parts.append("]") + elif isinstance(node, dict): + parts.append("{") + for i, (key, item) in enumerate(node.items()): + if i: + parts.append(",") + parts.append(_json.dumps(str(key), ensure_ascii=False)) + parts.append(":") + emit(item) + parts.append("}") + else: + raise TypeError( + f"json_stringify: read_json produced {type(node).__name__}, " + f"which is not a Json value; the ADT walk is wrong" + ) + + emit(value) + return "".join(parts) From 70231f9d8e38f2aaa403ae74fef442f21a3e95b3 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Thu, 13 Aug 2026 23:11:08 +0100 Subject: [PATCH 06/69] Make the browser Markdown renderer a fixed point, like the reference one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit md_render in vera/browser/runtime.mjs preserved a paragraph's internal soft line breaks and did not re-apply a container's prefix on output, so it broke the round-trip property spec §9.7.3 states for it and, with it, §12.9.3's identical-results requirement. The scope was any multi-line paragraph, not the list lazy continuation first observed, and the render was not stable: re-rendering its own output moved content out of its container, and on a blockquote wrapping a heading and a fenced block the second render fragmented the fence into three and lifted the code clean out of the quote, past recovery by any subsequent parse. Two defects underlay it, in two different phases, and neither alone closes the issue. The parser joined a paragraph's lines with "\n" where vera/markdown.py joins with a space: §9.7.3's design note excludes hard and soft line breaks from the ADT — "collapsed into paragraph text" — so a break that survives into MdText is one no renderer can tell from text the author wrote, and md_parse itself returned different ADTs on the two hosts. The renderer returned one string and threaded the container prefix down as an argument, which a container could only apply to the FIRST line of each child; it is now line-based, mirroring _render_block, so every caller re-applies its own prefix to every line it receives. That is the property that makes the render a fixed point. A third mirror lands with them: a code span containing a backtick renders with the longer `` … `` fence, which the reference renderer has always done and neither parser can produce, so it was reachable only from a constructed ADT. Spec §9.7.3 states both rules rather than leaving them an implementation detail each host had to rediscover. Tests: the two pinned-divergence assertions collapse into parity assertions, and the battery is three-layered — cross-host equality, the expected string, and stability under re-render — because equality alone passes two hosts that agree on a wrong answer and a single render passes a renderer that drifts on the second pass. The round-trip property runs over a nineteen-case corpus: the eight the reference renderer is already held to in tests/test_markdown.py, every one of which is single-line or fence-only and therefore blind to exactly this defect, plus the container and multi-line shapes the bug was about. Three further cases render ADTs a Vera program built rather than parsed. Six mutations of the fix — one per facet — were each confirmed to turn the battery red. Docs: KNOWN_ISSUES drops both browser-parity rows, and §12.9.3, §12.9.6, README, FAQ, TESTING, vera/README and the landing page stop describing the two operations as tracked divergences. Closes #1294 Co-Authored-By: Claude --- CHANGELOG.md | 3 + FAQ.md | 6 +- KNOWN_ISSUES.md | 2 - README.md | 4 +- ROADMAP.md | 2 +- TESTING.md | 4 +- docs/index.html | 2 +- docs/index.md | 2 +- docs/llms-full.txt | 6 +- scripts/build_site.py | 2 +- spec/09-standard-library.md | 9 +- spec/12-runtime.md | 10 +- tests/test_browser.py | 333 +++++++++++++++++++++++++----------- vera/README.md | 4 +- vera/browser/runtime.mjs | 96 ++++++++--- 15 files changed, 339 insertions(+), 146 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 251a4e8e..7b9eac84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **`new(State)` reads the cell its contract names** ([#1285](https://github.com/aallan/vera/issues/1285)). `old(State)` has been keyed on the resolved cell family since [#1205](https://github.com/aallan/vera/issues/1205)/[#1209](https://github.com/aallan/vera/issues/1209); `new(State)` read the name-keyed `_effect_ops["get"]`, which holds whichever `State` the effect row registered first. Under a single-`State` row the two keyings coincide, which is why the corpus agreed; under a multi-`State` row the two sides of one `ensures` clause read different cells. `effects(, State>)` with `ensures(new(State) == false)` was check-green *and* verify-green, emitted `state_get_Int`'s i64 into the Bool comparison's `i32.eq`, and died at load with wasmtime's raw `type mismatch: expected i32, found i64`. The type mismatch is the symptom rather than the defect: where both cells share a machine width — `State` beside `State` — the module loaded and answered about the other cell, so `ensures(new(State) == old(State))` on a function that writes neither was refuted at runtime on a contract the verifier had discharged. Codegen now carries a family→getter registry populated at the declared-row registration site from the per-family `CellNames` it already computes, and `_translate_new_expr` keys on `_state_effect_family` exactly as `_translate_old_expr` does. A bare `get(())` names no family and so is right to keep reading the name-keyed registry, source-order-first-wins; a contract names one and must not. +- **The browser runtime's `md_render` mirrors the reference renderer, holds the round-trip property, and is a fixed point** ([#1294](https://github.com/aallan/vera/issues/1294)). It preserved a paragraph's internal soft line breaks and did not re-apply a container's prefix (`> `, list-item indent) on output, so it broke the round-trip property spec §9.7.3 states for `md_render` and, with it, §12.9.3's identical-results requirement. The scope was any multi-line paragraph, not the list lazy continuation first observed, and the render was not stable: re-rendering its own output moved content out of its container (`> a b` → `> a\nb` → `> a\n\nb`, where `b` is no longer quoted), and on a blockquote wrapping a heading and a fenced block the second render fragmented the fence into three and lifted the code clean out of the quote, past recovery by any subsequent parse. Two defects underlay it, in two different phases, and both are fixed because neither alone closes the issue. The **parser** joined a paragraph's lines with `\n` where the reference parser joins with a space: §9.7.3's design note excludes hard and soft line breaks from the ADT — "collapsed into paragraph text" — so a break that survives into `MdText` is one no renderer can tell from text the author wrote, and `md_parse` itself therefore returned different ADTs on the two hosts. The **renderer** returned one string and threaded the container prefix down as an argument, which a container could only apply to the *first* line of each child; it is now line-based, mirroring `_render_block` in `vera/markdown.py`, so every caller re-applies its own prefix to every line it receives — the property that makes the render a fixed point. A third mirror lands with them: a code span containing a backtick now renders with the longer `` `` … `` `` fence, which the reference renderer has always done and neither parser can produce, so it was reachable only from a constructed ADT. Spec §9.7.3 states both rules rather than leaving them as an implementation detail two hosts had to rediscover. `tests/test_browser.py`'s two pinned-divergence assertions collapse into parity assertions, and the battery around them is now three-layered — cross-host equality, the expected string, and stability under re-render — because equality alone passes two hosts that agree on a wrong answer and a single render passes a renderer that drifts on the second pass. The §9.7.3 round-trip property is exercised over a nineteen-case corpus: the eight the reference renderer is already held to in `tests/test_markdown.py`, every one of which is single-line or fence-only and therefore blind to exactly this defect, plus the container and multi-line shapes the bug was about. Three further cases render ADTs a Vera program *built* rather than parsed, since the parser only reaches the shapes it happens to produce. + + - **`json_stringify` has one canonical output form, and both runtimes produce it** ([#1293](https://github.com/aallan/vera/issues/1293)). The two hosts disagreed: the reference runtime called `json.dumps(value, ensure_ascii=False, allow_nan=False)` — `", "` / `": "` separators, and since `read_json` hands it Python `float`s, a `JNumber` parsed from `1` re-rendered as `1.0` — where the browser called bare `JSON.stringify`. Spec §12.9.3 requires every non-IO operation to produce identical results in both runtimes, so the divergence itself was the defect, and §9.7.1 now states the resolution: the compact form, `,` and `:` with no padding, object members in insertion order, strings escaped with non-ASCII emitted literally, and numbers rendered by ECMAScript's `Number::toString`. The browser already emitted that form; the reference host moved to it. Measuring the gap first showed it was wider than the issue's two axes — `json.dumps` renders floats with `repr`, hard-wired inside `json.encoder` and not reachable through any separator setting, and `repr` disagrees with `Number::toString` on **four** independent boundaries, not one: the fractional part of an integral value (`1.0` vs `1`), the threshold for exponential notation at each end of the range (`1e+16` vs `10000000000000000`, `1e-06` vs `0.000001`), and the spelling of the exponent itself (`1e-07` vs `1e-7`), plus negative zero (`-0.0` vs `0`). Fixing only the reported symptom would have left the other three diverging, so `vera/wasm/json_serde.py` gains `format_json_number`, an implementation of ECMA-262 §6.1.6.1.20 that takes its shortest-round-trip digits from `repr` and recomputes only their placement; string escaping is still delegated to `json.dumps`, which already agrees with `JSON.stringify` byte for byte. The claim "matches ECMAScript" is checked differentially against the real `JSON.stringify` over 2,000 doubles drawn from raw bit patterns, not only against a hand-written boundary table, since a table proves the cases its author thought of and those are the cases the code was written to handle. Alongside the formatting, the third asymmetry the issue folds in is closed in the other direction: a `JNumber` holding `NaN` or an infinity now **fails on both hosts** with the same sentence, where the browser used to emit `null` — swapping a value RFC 8259 cannot carry for a different, perfectly valid one that no consumer could tell from a genuine `JNull`. The eleven Node-only tag assertions in `tests/test_browser.py` become full parity assertions, the two pinned-divergence strings collapse into single-truth ones, and the battery gains the number boundaries, a three-pass idempotence check on every case, and the two-sided failure assertion for non-finite values (it must raise *and* print nothing, so a host that emitted `null` before failing cannot read as a pass). diff --git a/FAQ.md b/FAQ.md index 4f0dde3f..eef9d5b7 100644 --- a/FAQ.md +++ b/FAQ.md @@ -170,9 +170,9 @@ vera compile --target browser examples/hello_world.vera # index.html ``` -Serve it with any HTTP server and open `index.html` — no build step, no bundler, no dependencies. The JavaScript runtime provides browser-appropriate implementations of all Vera host bindings: `IO.print` writes to the page, `IO.read_line` uses `prompt()`, and all other operations (State, contracts, Markdown) work identically to the wasmtime runtime, with two documented exceptions: `json_stringify` ([#1293](https://github.com/aallan/vera/issues/1293)) and `md_render` ([#1294](https://github.com/aallan/vera/issues/1294)) still differ between the two hosts. +Serve it with any HTTP server and open `index.html` — no build step, no bundler, no dependencies. The JavaScript runtime provides browser-appropriate implementations of all Vera host bindings: `IO.print` writes to the page, `IO.read_line` uses `prompt()`, and all other operations (State, contracts, JSON, Markdown) work identically to the wasmtime runtime. `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states — §9.7.1 and §9.7.3 — rather than by the two hosts happening to agree, which is what the parity suite checks them against. -Two effects are refused outright rather than merely differing. `Inference` and `DB` return an explanatory `Err` from every operation in the browser, because the API key or database credential they would need is readable from page source and network traffic in client-side JavaScript. Reach them through a server-side endpoint and call it with `Http`, which does run in the browser — it is backed by `XMLHttpRequest`, not a stub. That refusal is a deliberate platform boundary, not a divergence awaiting a fix like the two above; spec §9.5.5 states it for `Inference`. +Two effects are refused outright rather than merely differing. `Inference` and `DB` return an explanatory `Err` from every operation in the browser, because the API key or database credential they would need is readable from page source and network traffic in client-side JavaScript. Reach them through a server-side endpoint and call it with `Http`, which does run in the browser — it is backed by `XMLHttpRequest`, not a stub. That refusal is a deliberate platform boundary; spec §9.5.5 states it for `Inference`. The runtime also works in Node.js: @@ -279,7 +279,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 10,648 tests, including a 216-program conformance suite +- 10,676 tests, including a 216-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 04f76c68..ca4df160 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -11,8 +11,6 @@ Defects in shipped compiler, runtime, or tooling behaviour — this table matche | Codegen's `_known_fns` mirrors the flat `_fn_sigs`, which keys a name the call site **cannot see** under its bare name, so the #1284 ownership predicate answers "user-owned" at a site where the checker resolved the effect operation — one rule, two tables that disagree. Three demonstrated routes: a `private fn get` in an imported module (still reachable, so still emitted); a `public fn get` a selective import excludes; and a `where` helper of a **`forall` parent**, which keeps a bare `_fn_sigs` key beside its clone-qualified one (`helper`/`id` in `ch09_generic_where_helper.vera`) where a non-generic parent's helper does not. All three are check-green, and how the failure lands depends on the route and on the widths: the two import routes are the dangerous ones — where both cells are i64 the module loads and returns the invisible helper's value instead of the cell's (**silent wrong value**), and where the widths differ it fails to load (`type mismatch: expected i64, found i32`). The generic-`where` route is always **loud**: the bare key exists in the signature table while no bare symbol is emitted (the helper is only ever `holder$Bool$where$get`), so the call fails at WAT assembly with `unknown func: failed to find name $get` and no E-code. The checker's answer is nailed by a type oracle rather than asserted — the invisible `get` returns `@Bool` while the caller returns `@Int` from `get(())` and checks green, which is only possible if the checker typed the call from the `State` cell — and each route carries a rename control that returns the checker's value. Pre-existing, not introduced by the #1284 fix, which made the rule one function without making either table scope-accurate. Fix direction: `_known_fns` becomes the names visible in the compiling declaration's **lexical** scope — module scope is not enough, since a generic's `where` helper *is* in the module and still is not in a sibling's scope — or the flattening stops keying invisible names bare, the `mod$…` renaming `_resolve_module_call_wasm_name` already applies to shadowed generics being the precedent. Same module-namespace table family as [#1281](https://github.com/aallan/vera/issues/1281). Spec §7.4 states the language rule ("declarations first — a property of the call site's scope alone"); this row tracks codegen's violation of it. | [#1299](https://github.com/aallan/vera/issues/1299) | | A postcondition may name a `State` the function's effect row never declares, and `vera check` accepts it: `ensures(new(State) == false)` under `effects(>)` reports OK, then `vera compile` fails with **E699** — the internal-compiler-error diagnostic whose own text says the type checker should have rejected the input, which is exactly the situation. Both forms land there for the same reason (no cell of that family exists, so `old()` finds no snapshot local and, since [#1285](https://github.com/aallan/vera/issues/1285), `new()` finds no getter). Loud and never a wrong answer, so this is diagnostic quality rather than soundness — but it is a check-green program that cannot compile, reported against the compiler instead of against the program, with a bug-report request the user should not act on. Before #1285 the `new()` side was worse than loud: the name-keyed lookup found the row's other getter and silently read the wrong cell. Fix direction: validate an `OldExpr`/`NewExpr`'s effect reference against the declared row where the checker already validates the rest of the clause, one rule for both forms; `test_a_family_the_row_does_not_declare_is_loud_on_both_sides` pins today's E699 and is the test to flip. | [#1298](https://github.com/aallan/vera/issues/1298) | | Spec §1.4 says its reserved keywords MUST NOT be used as function names, and for **seventeen of them nothing enforces it**: `then`, `else`, `data`, `type`, `module`, `import`, `public`, `private`, `requires`, `ensures`, `invariant`, `decreases`, `effect`, `with`, `in`, `where` and `pure` all declare and pass `vera check` as `public fn (@Int -> @Int)`. (`handle` is an eighteenth acceptance and is the sanctioned host-entry-point carve-out, §5.2; the other eleven names — `resume`, `exists`, `forall`, `match`, `assert`, `assume`, `fn`, `let`, `if`, `true`, `false` — are refused **E153**.) They are not even traps: `private fn with(@Int -> @Int)` and its `where` / `type` / `pure` / `requires` / `import` siblings are **callable** from the same file, so the declaration resolves and runs like any other function. The comment above `_KEYWORD_FN_NAMES` in `vera/checker/registration.py` gives the opposite as the reason those names are absent from the set — that the contextual lexer "does not admit them as a function name, so no declaration reaches this checker at all" — so the omission rests on a premise the tree refutes, and neither the parser nor the checker holds the MUST. Two resolutions, and the choice is a ruling rather than a mechanical fix: extend `_KEYWORD_FN_NAMES` so the seventeen join `resume` under E153, or narrow §1.4's MUST to the names actually reserved. The `resume` precedent leans to enforcing — `resume` is likewise not a keyword token anywhere and likewise parses, and was reserved *at the checker* precisely because the parser had no keyword to refuse it with — and E153's own rationale, that a keyword names one construct and nothing else (DESIGN principle 3), does not depend on the name being a trap. Until the ruling lands, §1.4's `handle` carve-out sentence deliberately makes no enforcement claim, so the spec does not assert a MUST it cannot back. | [#1296](https://github.com/aallan/vera/issues/1296) | -| The browser runtime's `md_render` breaks the round-trip property spec §9.7.6 states for it — `md_parse(md_render(b)) == Ok(b)` does not hold for its output — and with it §12.9.3's identical-results requirement, which names Markdown explicitly. The scope is **any multi-line paragraph**, not the list lazy continuation first observed: the browser preserves a paragraph's internal soft line breaks and does not re-apply the container prefix (`> `, list-item indent) on output, where the native renderer collapses them to spaces. The browser render is also **not stable** — re-rendering its own output moves content out of its container, so `> a b` renders as two lines and then re-renders with the second line no longer quoted — and on a blockquote wrapping a heading and a fenced block it destroys the document: the fence fragments into three and its contents escape the quote, past recovery by any subsequent parse. The native renderer is a fixed point on every case. Parsing is unaffected — `md_has_heading`, `md_has_code_block` and `md_extract_code_blocks` are byte-identical across runtimes on the same inputs — so the defect is scoped precisely to the renderer in `vera/browser/runtime.mjs`. `tests/test_browser.py` pins both runtimes' exact current strings, including the destructive blockquote case, so a fix on either side goes red until the pins are updated deliberately. | [#1294](https://github.com/aallan/vera/issues/1294) | -| `json_stringify` output differs between the native and browser runtimes on two independent axes: **separators** — `json.dumps` defaults to `", "` / `": "` where `JSON.stringify` emits none — and **integral numbers**, since native `read_json` produces a Python `float`, so a number parsed from `1` re-renders as `1.0` where the browser renders `1`. Neither host is individually wrong (spec §9.7.5 pins no output format and names both libraries), but §12.9.3 requires all non-IO operations to produce identical results in both runtimes, so the divergence itself is the defect. Key ordering is insertion order on both sides and both are idempotent under re-stringify. A third asymmetry folds in here: stringifying a NaN number **traps** natively (`allow_nan=False` in `vera/runtime/json.py`) while the browser silently emits `null` — crash versus silent wrong value. Fix direction, per DESIGN principle 3 (one canonical form): pick one serialization and state it in §9.7.5; the compact `JSON.stringify` form is the more conventional and avoids the `1` to `1.0` document mutation, which makes the native side the better candidate to change. `tests/test_browser.py` pins both runtimes' exact strings — five assertions there go red when the hosts converge, and eleven Node-only tag tests become full parity tests. | [#1293](https://github.com/aallan/vera/issues/1293) | | Spec Chapter 10 is a second, hand-maintained copy of `vera/grammar.lark`, and the rule-name alignment gate `scripts/check_grammar_alignment.py` compares only rule-name headers — three classes of drift pass it unseen. **Terminals, both directions**: the header pattern requires a lowercase lead, so a fabricated terminal added to §10.2 leaves the gate green, and no declared-versus-referenced audit exists in either direction. **Rule references**: restoring a removed ambiguity to a production's right-hand side is invisible, because only headers are compared. **Production bodies**: the class most grammar edits actually fall into. Two instances are live in the chapter on `main` today, both body-level and both this issue's to close: typed holes (`"?" -> hole_expr`, in `grammar.lark` since 2026-03-30) appear nowhere in `primary_expr`, and §10.2's `BLOCK_COMMENT: /\{-[\s\S]*?-\}/` is non-nesting, contradicting both §1.3 ("They nest") and the implementation, which parses `{- a {- b -} c -}` clean. Never a wrong answer from a program — the defect is that the published grammar misdescribes the one the parser has. Fix direction: extend the gate side-aware (terminal audit both ways, reference-set comparison), or fold Chapter 10 toward the DESIGN Grammar row's actual promise of a single-sourced shared grammar rather than a hand-maintained copy held honest by ever-wider cross-checks; the two live instances are fixable independently of which direction wins. | [#1290](https://github.com/aallan/vera/issues/1290) | | `.github/workflows/release.yml`'s `Tag and create GitHub Release` step fails with `HTTP 422: Validation Failed — body is too long (maximum is 125000 characters)` when the CHANGELOG section `scripts/release.py notes` extracts into `RELEASE_NOTES.md` exceeds GitHub's release-body limit. It fired on v0.1.10, whose notes extract to roughly 148,700 bytes — the #1213 burndown's 44-issue section, some 23,000 characters past the limit — and it fired at the worst point in the pipeline: **after** PyPI had accepted the immutable archives and **after** the tag was created, leaving the release half-cut with no repeatable path back. The v0.1.10 GitHub Release was completed by hand, mirroring the step exactly: the run's artifacts downloaded and hash-verified three ways, then `gh release create --verify-tag --latest` with the wheel, sdist and SHA256SUMS, and a generated body — the section's bold bullet lead-ins as a headline index plus a link to the canonical section at the tag. Rare, since it needs a release this large, but rarity is not the mitigating factor here; the landing point is. Fix direction: make the step total — before `gh release create`, regenerate oversized notes into that index form so the release always carries a body that fits, with the full notes staying in `CHANGELOG.md`, which is already the release notes of record. | [#1288](https://github.com/aallan/vera/issues/1288) | | `_stamp_decl_order` skips the prelude stamp when a main-file `type` reuses a prelude name: `type Option = Int` checks green and leaves the prelude's `Option` without `_prelude_decl_order`, so inside a module namespace it resolves at `_BUILTIN_DECL_INDEX` instead of its prelude position. Latent — the shadow wins the arbitration by namespace rules before the index is consulted, and no observably-wrong check-green program has been constructed — but the stamp is wrong and the next decl-order consumer inherits it. Fix direction: stamp the prelude declaration unconditionally; its order is a fact about the prelude, not about what the main file declares. | [#1287](https://github.com/aallan/vera/issues/1287) | diff --git a/README.md b/README.md index 3e67e36e..c239a6e9 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,7 @@ vera effects --json # list the effect and ability registry vera errors --json # list the diagnostic-code registry: E001–E702 + W001/W002 (no file needed) ``` -`vera compile --target browser` produces a self-contained bundle (wasm + JS runtime + HTML) that runs in any browser — no build step, no bundler. Mandatory parity tests ensure identical behaviour between the command-line and browser runtimes for the pure-language surface (arithmetic, ADTs, pattern matching, closures, contracts, effects-as-host-imports, etc.), with two exceptions on that surface: `json_stringify` ([#1293](https://github.com/aallan/vera/issues/1293)) and `md_render` ([#1294](https://github.com/aallan/vera/issues/1294)) are tracked bugs where the two hosts disagree, so each one's current output is pinned per host until they agree. Distinct from those, and not divergences at all: `Inference.complete`, `DB.query` and `DB.execute` return `Err` from every browser call by definition of the target, because the credential each needs would be readable from page source — reach them through a server-side endpoint called with `Http`, which does run in the browser. The IO surface is the other documented exception: terminal Vera programs that rely on `IO.sleep` for animation pacing or ANSI escape codes for cursor control compile cleanly to `--target browser` but render the escapes as literal text and freeze the tab while sleeping — the browser target expects Vera to be the pure simulation core and JavaScript to drive timing and rendering ([SKILL.md §Browser compilation](SKILL.md#browser-compilation) has the recommended pattern). +`vera compile --target browser` produces a self-contained bundle (wasm + JS runtime + HTML) that runs in any browser — no build step, no bundler. Mandatory parity tests ensure identical behaviour between the command-line and browser runtimes for the pure-language surface (arithmetic, ADTs, pattern matching, closures, contracts, effects-as-host-imports, etc.). Two operations on that surface reach identity by emitting a canonical form the specification states rather than by the hosts happening to agree — `json_stringify` (spec §9.7.1) and `md_render` (§9.7.3) — so their tests assert the expected string as well as cross-host equality. Distinct from that: `Inference.complete`, `DB.query` and `DB.execute` return `Err` from every browser call by definition of the target, because the credential each needs would be readable from page source — reach them through a server-side endpoint called with `Http`, which does run in the browser. The IO surface is the other documented exception: terminal Vera programs that rely on `IO.sleep` for animation pacing or ANSI escape codes for cursor control compile cleanly to `--target browser` but render the escapes as literal text and freeze the tab while sleeping — the browser target expects Vera to be the pure simulation core and JavaScript to drive timing and rendering ([SKILL.md §Browser compilation](SKILL.md#browser-compilation) has the recommended pattern). `vera compile --target wasi-p2` emits an **experimental WASI Preview 2 target (IO and Random surface)**: a binary WebAssembly component whose host imports are implemented over WASI 0.2 interfaces, runnable by any stock wasip2 host (`wasmtime run` needs no flags and no Vera bindings). Programs using host families beyond IO/Random are rejected with a diagnostic naming the family — never silently compiled against the core target. See [spec chapter 13](spec/13-wasi.md) for the architecture, the supported surface, and the documented divergences (WASI 0.2's ok/err-only exit codes, no structured trap frames across the component boundary). With `--world server`, the same contract-verified `handle(Request -> Response)` program `vera serve` hosts natively compiles to a `wasi:http/incoming-handler` component that stock `wasmtime serve` runs unmodified — verified HTTP handlers as a portable deployment artifact (`--world server` is only valid together with `--target wasi-p2`; the CLI rejects other combinations). @@ -263,7 +263,7 @@ cp /path/to/vera/SKILL.md ~/.claude/skills/vera-language/SKILL.md ## Project status -Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 10,648 tests, 95% Python code coverage, 216 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. +Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 10,676 tests, 95% Python code coverage, 216 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. The reference compiler — parser, AST, type checker, contract verifier (Z3), WASM code generator, module system, browser runtime, and runtime contract insertion — is working. The language specification is in draft across [14 chapters](spec/). diff --git a/ROADMAP.md b/ROADMAP.md index 65c1dd43..7e02e4d6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,7 +12,7 @@ Ordering derives from the design principles ([DESIGN.md](DESIGN.md)): verificati ## Where we are -10,648 tests, 216 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. +10,676 tests, 216 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. ## Stage 19 — The verification completeness sprint diff --git a/TESTING.md b/TESTING.md index 7cbd83c2..b57d9ecd 100644 --- a/TESTING.md +++ b/TESTING.md @@ -6,7 +6,7 @@ This is the single source of truth for Vera's testing infrastructure, coverage d | Metric | Value | |--------|-------| -| **Tests** | 10,648 across 164 files (~152,000 lines of test code; 10,480 passed + 26 stress, 142 skipped) | +| **Tests** | 10,676 across 164 files (~152,000 lines of test code; 10,508 passed + 26 stress, 142 skipped) | | **Compiler code coverage** | 95% Python, 87% JavaScript (CI minimum: 80%) | | **Conformance programs** | 216 programs across 9 spec chapters, validating every language feature | | **Example programs** | 42, all validated through `vera check` + `vera verify` | @@ -196,7 +196,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_tester_coverage.py` | 49 | 1,395 | Tester coverage gaps: String/Float64/ADT parameter input generation, Bool/Byte parameters, unsatisfiable preconditions, type expression edge cases, FP model-value extraction (NaN/Inf/signed-zero, #797), the #1208 naming pin (the threaded alias environment canonicalizes a type-argument alias), and the #1216 resolution set — an alias-typed parameter is trialed rather than skipped, its alias-spelled `requires` constrains the generated inputs, a refined alias reaches Z3 with its predicate so no trial violates codegen's entry guard, a `forall` variable shadowing a same-named module alias still resolves to an unsupported type variable, and a parameter that resolves to a non-encodable type still skips with that type named; plus the #1229 set — an UNTRANSLATABLE input constraint skips the function naming the blocking conjunct instead of scoring the resulting entry-guard trap as a contract failure, on the reported `string_length` repro, on a mixed clause (only the untranslatable conjunct is named), on a quantified precondition and on a refined parameter's predicate, with a Tier-3 control that must still be tested so a skip taxonomy firing on everything cannot pass | | `test_markdown.py` | 59 | 393 | Markdown parser: block/inline parsing, rendering, round-trips, edge cases | | `test_lsp.py` | 146 | 2470 | LSP transport + coordinate layer (#222 Phase C) and language features (#222 Phase D): parametrized code-point↔UTF-16 goldens incl. astral-plane fixtures and surrogate-pair snapping, Span (1-based, exclusive-end) and SourceLocation (0-based col) → LSP Range conversions, point→token-range widening, DocumentStore open/change/close + index invalidation, an in-process handler-drive test, and one stdio end-to-end round-trip against the real `vera lsp` subprocess (initialize → didOpen → shutdown → exit) pinning serverInfo + textDocumentSync capabilities; plus the Phase D feature suite — parse-error single-diagnostic path, type-error verification short-circuit, tier=3 in E520 diagnostic data, per-function tier Hint synthesis (and its suppression for functions with violated obligations), smallest-enclosing-span hover, De Bruijn slot goto (most-recent-parameter jump, out-of-range None, off-slot None, and the #1208 keying pins: a parameterised reference resolves, an alias-spelled parameter is reachable from a canonically-spelled reference, and a `forall` variable shadowing a module alias lands on the right parameter), and typed-hole completion (inside/after hole, away-from-hole None); plus the Phase E speculativeEdit suite — identical-text all-unchanged, breaking edit surfaces newly_undischarged (violated nat_sub) with canonical state untouched, strengthening edit surfaces newly_discharged, parse/type errors report ok:false, deleted functions report removed, proof_delta purity; plus the Phase F1 proposeEdit suite — the apply gate (clean and strengthening edits apply, breaking and non-compiling edits refuse), force overriding both gates with the delta still reported, wiring against a structural fake server (apply round-trip with exact full-document replacement range, refuse touches no canonical state, unopened-URI clamp sentinel), and full-document-range goldens (trailing-newline virtual line, UTF-16 end column); plus the Phase F2 strengthenContract suite — splice goldens (first-clause-only replacement with byte-identical remainder, ensures variant, unknown-fn None), the call-site audit pin (tightened precondition refused with newly_undischarged call_pre items, canonical state untouched), provable-ensures strengthening applies, and the three splice-target refusal paths (no analysis, unparseable document, unknown function); plus the Phase F3 addEffect suite — transitive-caller closure goldens (diamond in declaration order, leaf, unknown-fn None, recursion appears once), handler bounding (#725: a caller discharging the effect around its only call site drops out of the closure and is not rewritten, while a second unhandled path, a call in a handler clause, a handler for another effect, a handler naming a different instance of the same effect (`handle[State]` against a `State` propagation, which the checker does not discharge — end-to-end that caller must still be rewritten and the candidate must still apply, with the matching `State` propagation against the same fixture as the positive control that this handler key does prune something), and a bare `where`-helper call all keep it in, as does a refinement type argument at either depth (`Exn<{ @Int \| p }>` and `Exn>` render as their bare base type but discharge nothing of `Exn` — also pinned end-to-end), while an unparameterised `handle[IO]` does bound an `IO` propagation, a whitespace-spelled `State< Int >` request still bounds, and nesting bounds in either order (a matching handler inside a foreign one, a foreign one inside a matching one) — plus a key-level pin that `handle[Mod.IO]` keeps its module, and the effect-less query pinned handler-unaware; plus the two boundary pins the review added — a call in the handler's STATE INITIALISER keeps its edge, since the initialiser is evaluated in the enclosing scope before the handler is installed (pinned beside the E125 the checker raises there against a `pure` caller, with the identical call in the handler body clean as the contrast), and an alias-spelled handler does not bound a `State` propagation though the checker discharges it (`handle[State]` with `type MyAlias = Int` — the spelling comparison's under-prune, #1292, with the alias-spelled request as the control that does prune)), effect-row rewrite goldens (pure to singleton set, source-preserving append, already-present None, base-name identity blocking State next to State), diamond propagation applying one multi-site candidate with the bystander untouched, mixed append/replace rows with already-satisfied callers skipped, the fully-satisfied no-op shape, and the two refusal paths; plus the #728 instruction-contract suite — the LSP message carries description, rationale, and the Fix: paragraph (also pinning single E501 emission at the LSP surface), and a bare diagnostic maps to the description alone | -| `test_browser.py` | 227 | 4,075 | Browser parity: Python/wasmtime vs Node.js/JS-runtime output equivalence across IO, State, contracts, Markdown, Regex, and the examples the browser target can execute (two explicit lists in the file, not the whole `examples/` directory — interactive stdin, file IO, `DB` and the non-standalone `modules` example are excluded with their reasons recorded); plus the #349 `runtime.mjs` coverage battery — per-value-type and per-key-type Map variants, per-element-type Set variants, cold `Decimal` branches (exact-zero sign, negative-shift division, `decRoundPlaces` special cases, non-finite storage), `readJson`/`json_stringify` across every Json ADT tag, the Regex/Json `Result.Err` arms, and nested-Markdown walks. Two cases pin browser↔native divergences the coverage work uncovered by asserting each runtime's exact output separately — both are tracked bugs, not deliberate boundaries — `json_stringify` (bare `JSON.stringify` vs `json.dumps`: separator padding and integral-number rendering, [#1293](https://github.com/aallan/vera/issues/1293)) and `md_render` on **any multi-line paragraph**, where the browser keeps the paragraph's internal soft line break and does not re-apply the container prefix, its render is not stable under re-render, and a blockquote wrapping a heading and a fenced block is destroyed outright the second time round ([#1294](https://github.com/aallan/vera/issues/1294)) — so a change on either side goes red instead of being absorbed as an expected failure | +| `test_browser.py` | 255 | 4,214 | Browser parity: Python/wasmtime vs Node.js/JS-runtime output equivalence across IO, State, contracts, Markdown, Regex, and the examples the browser target can execute (two explicit lists in the file, not the whole `examples/` directory — interactive stdin, file IO, `DB` and the non-standalone `modules` example are excluded with their reasons recorded); plus the #349 `runtime.mjs` coverage battery — per-value-type and per-key-type Map variants, per-element-type Set variants, cold `Decimal` branches (exact-zero sign, negative-shift division, `decRoundPlaces` special cases, non-finite storage), `readJson`/`json_stringify` across every Json ADT tag, the Regex/Json `Result.Err` arms, and nested-Markdown walks. Two operations carry a canonical form the specification states rather than merely agreeing across the hosts, so their batteries assert more than equality: `json_stringify` (spec §9.7.1) pins the expected string on every Json ADT tag and on the number-rendering boundaries, checks three-pass idempotence, checks that a non-finite `JNumber` fails on both hosts *and* prints nothing, and checks the reference host's own number rendering differentially against a real `JSON.stringify` over doubles drawn from raw bit patterns; `md_render` (§9.7.3) pins the expected render, re-renders it to prove the fixed point, and runs the round-trip property over a corpus that includes the container and multi-line shapes a flat corpus misses. Both were host-divergent until v0.1.12 ([#1293](https://github.com/aallan/vera/issues/1293), [#1294](https://github.com/aallan/vera/issues/1294)) | | `test_conformance.py` | 1080 | 125 | Parametrized conformance suite: parse, check, verify, run, format idempotency across 216 programs | | `test_prelude.py` | 29 | 585 | Prelude injection: Option/Result/array operation detection, combinator shadowing, type aliases, the reserved namespace every injected alias declaration lives in — checked against the checker's own E154 regex rather than a second spelling of the rule, since an alias the prelude injects outside it is one codegen resolves and the checker leaves opaque (#1184/#1221) — end-to-end compilation | | `test_checker_apply_fn.py` | 18 | 455 | #854 — `apply_fn` as a checker special form: zero-warning pins (API + CLI `--json` + closures.vera), E201 arity / E202 type / non-function-first-arg errors, E122/E125 effect-row enforcement for applied fn values, E151 redefinition rejection, variadic two-param application, prelude combinator regression pins | diff --git a/docs/index.html b/docs/index.html index 5b782861..411bf2be 100644 --- a/docs/index.html +++ b/docs/index.html @@ -576,7 +576,7 @@

Browser

runtime.mjs index.html -

Self-contained — no bundler. Serve with any HTTP server (python -m http.server). IO.print writes to the page; every other operation the browser target supports works identically to the CLI, apart from two tracked bugs where the hosts disagree — json_stringify (#1293) and md_render (#1294). Parity tests enforce this on every PR, pinning each host's current output for those two so a fix goes red rather than passing unnoticed. Note: Inference.complete and every DB operation return an error in the browser — a deliberate platform boundary, since the credentials they need would be readable from page source; reach them through a server-side proxy via Http.

+

Self-contained — no bundler. Serve with any HTTP server (python -m http.server). IO.print writes to the page; every other operation the browser target supports works identically to the CLI. json_stringify and md_render reach that identity by emitting a canonical form the specification states (§9.7.1, §9.7.3) rather than by the hosts happening to agree, so parity tests check them against that form as well as against each other, on every PR. Note: Inference.complete and every DB operation return an error in the browser — a deliberate platform boundary, since the credentials they need would be readable from page source; reach them through a server-side proxy via Http.

WASI components

diff --git a/docs/index.md b/docs/index.md index 67c01a29..db7f4b57 100644 --- a/docs/index.md +++ b/docs/index.md @@ -217,7 +217,7 @@ Browser bundle: examples/hello_world_browser/ index.html ``` -Self-contained — no bundler. Serve with any HTTP server (`python -m http.server`). `IO.print` writes to the page; every other operation the browser target supports works identically to the CLI, apart from two tracked bugs where the hosts disagree — `json_stringify` ([#1293](https://github.com/aallan/vera/issues/1293)) and `md_render` ([#1294](https://github.com/aallan/vera/issues/1294)). Parity tests enforce this on every PR, pinning each host's current output for those two so a fix goes red rather than passing unnoticed. *Note: `Inference.complete` and every `DB` operation return an error in the browser — a deliberate platform boundary, since the credentials they need would be readable from page source; reach them through a server-side proxy via `Http`.* +Self-contained — no bundler. Serve with any HTTP server (`python -m http.server`). `IO.print` writes to the page; every other operation the browser target supports works identically to the CLI. `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states (§9.7.1, §9.7.3) rather than by the hosts happening to agree, so parity tests check them against that form as well as against each other, on every PR. *Note: `Inference.complete` and every `DB` operation return an error in the browser — a deliberate platform boundary, since the credentials they need would be readable from page source; reach them through a server-side proxy via `Http`.* ### WASI components diff --git a/docs/llms-full.txt b/docs/llms-full.txt index de3d2649..f4e78303 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -3147,9 +3147,9 @@ vera compile --target browser examples/hello_world.vera # index.html ``` -Serve it with any HTTP server and open `index.html` — no build step, no bundler, no dependencies. The JavaScript runtime provides browser-appropriate implementations of all Vera host bindings: `IO.print` writes to the page, `IO.read_line` uses `prompt()`, and all other operations (State, contracts, Markdown) work identically to the wasmtime runtime, with two documented exceptions: `json_stringify` ([#1293](https://github.com/aallan/vera/issues/1293)) and `md_render` ([#1294](https://github.com/aallan/vera/issues/1294)) still differ between the two hosts. +Serve it with any HTTP server and open `index.html` — no build step, no bundler, no dependencies. The JavaScript runtime provides browser-appropriate implementations of all Vera host bindings: `IO.print` writes to the page, `IO.read_line` uses `prompt()`, and all other operations (State, contracts, JSON, Markdown) work identically to the wasmtime runtime. `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states — §9.7.1 and §9.7.3 — rather than by the two hosts happening to agree, which is what the parity suite checks them against. -Two effects are refused outright rather than merely differing. `Inference` and `DB` return an explanatory `Err` from every operation in the browser, because the API key or database credential they would need is readable from page source and network traffic in client-side JavaScript. Reach them through a server-side endpoint and call it with `Http`, which does run in the browser — it is backed by `XMLHttpRequest`, not a stub. That refusal is a deliberate platform boundary, not a divergence awaiting a fix like the two above; spec §9.5.5 states it for `Inference`. +Two effects are refused outright rather than merely differing. `Inference` and `DB` return an explanatory `Err` from every operation in the browser, because the API key or database credential they would need is readable from page source and network traffic in client-side JavaScript. Reach them through a server-side endpoint and call it with `Http`, which does run in the browser — it is backed by `XMLHttpRequest`, not a stub. That refusal is a deliberate platform boundary; spec §9.5.5 states it for `Inference`. The runtime also works in Node.js: @@ -3256,7 +3256,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 10,648 tests, including a 216-program conformance suite +- 10,676 tests, including a 216-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/scripts/build_site.py b/scripts/build_site.py index 3f21fc16..6bab0545 100644 --- a/scripts/build_site.py +++ b/scripts/build_site.py @@ -615,7 +615,7 @@ def build_index_md(version: str) -> str: index.html ``` -Self-contained — no bundler. Serve with any HTTP server (`python -m http.server`). `IO.print` writes to the page; every other operation the browser target supports works identically to the CLI, apart from two tracked bugs where the hosts disagree — `json_stringify` ([#1293](https://github.com/aallan/vera/issues/1293)) and `md_render` ([#1294](https://github.com/aallan/vera/issues/1294)). Parity tests enforce this on every PR, pinning each host's current output for those two so a fix goes red rather than passing unnoticed. *Note: `Inference.complete` and every `DB` operation return an error in the browser — a deliberate platform boundary, since the credentials they need would be readable from page source; reach them through a server-side proxy via `Http`.* +Self-contained — no bundler. Serve with any HTTP server (`python -m http.server`). `IO.print` writes to the page; every other operation the browser target supports works identically to the CLI. `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states (§9.7.1, §9.7.3) rather than by the hosts happening to agree, so parity tests check them against that form as well as against each other, on every PR. *Note: `Inference.complete` and every `DB` operation return an error in the browser — a deliberate platform boundary, since the credentials they need would be readable from page source; reach them through a server-side proxy via `Http`.* ### WASI components diff --git a/spec/09-standard-library.md b/spec/09-standard-library.md index a2e373b7..7d1f92c4 100644 --- a/spec/09-standard-library.md +++ b/spec/09-standard-library.md @@ -2267,7 +2267,14 @@ public fn md_render(@MdBlock -> @String) effects(pure) ``` -Renders an `MdBlock` to a canonical Markdown string. Always succeeds. The round-trip property `md_parse(md_render(b)) == Ok(b)` should hold: rendering then re-parsing preserves structure. +Renders an `MdBlock` to a canonical Markdown string. Always succeeds. The round-trip property `md_parse(md_render(b)) == Ok(b)` should hold: rendering then re-parsing preserves structure. Every runtime produces the same string (§12.9.3). + +Two rules carry that property, and both follow from the ADT having no line-break node (see the design note above): + +- **A paragraph is one line.** Its inline content is rendered without internal newlines. A parser collapses a paragraph's soft line breaks to spaces on the way in, so nothing survives to be re-emitted. +- **A container prefixes every line of every child.** A block quote writes `> ` before each rendered line and a bare `>` for a blank one; a list item writes its marker before the first line and an equal-width indent before the rest. Prefixing only a child's first line would leave a fenced block's body — or a nested block's continuation — outside its container, and the next `md_parse` would read it as a sibling. + +Together these make `md_render` a fixed point: rendering its own output returns the same bytes. **Accessor functions for contracts:** diff --git a/spec/12-runtime.md b/spec/12-runtime.md index 86097c1f..de91156e 100644 --- a/spec/12-runtime.md +++ b/spec/12-runtime.md @@ -658,9 +658,11 @@ The rows above fall into three kinds, and the distinction matters when reading a - **Deliberate boundaries.** `IO.read_file` / `IO.write_file` (no filesystem), `` (no accept loop), and `Inference.complete` / `DB.query` / `DB.execute` (the credential would be readable from page source and network traffic) return `Err` on every call **by definition of the browser target**, not pending a fix. Reach a filesystem, a database or a model provider through a server-side endpoint and call it with `Http`, which does run in the browser. - **Not yet implemented.** `IO.read_char` is the only row of this kind: the browser `Err` is a stub awaiting JSPI suspend/resume ([#609](https://github.com/aallan/vera/issues/609)), so unlike a boundary it is expected to become an `Ok` one day. -Across the surface the two runtimes actually share — State, contracts, Markdown and the rest of the non-IO operations — results are identical, with two exceptions that are tracked bugs rather than part of the browser target's definition: `json_stringify` ([#1293](https://github.com/aallan/vera/issues/1293)) and `md_render` ([#1294](https://github.com/aallan/vera/issues/1294)) differ between the hosts. The boundary rows are outside that claim by construction, having no browser counterpart to agree with, and an `Http` call's outcome is host-specific for a milder reason: the browser issues it through synchronous `XMLHttpRequest`, so a JavaScript host without one returns an explanatory `Err` where the reference runtime performs the request. +Across the surface the two runtimes actually share — State, contracts, JSON, Markdown and the rest of the non-IO operations — results are identical. The boundary rows are outside that claim by construction, having no browser counterpart to agree with, and an `Http` call's outcome is host-specific for a milder reason: the browser issues it through synchronous `XMLHttpRequest`, so a JavaScript host without one returns an explanatory `Err` where the reference runtime performs the request. -Fused async preserves the underlying value whenever the two hosts' results are comparable. Only the evaluation strategy differs, which is spec-conformant — §9.5.4 says an implementation MAY evaluate `async(e)` concurrently — and any difference that remains comes from the `Http` outcome underneath rather than from `async` itself. Mandatory parity tests enforce identical results across the shared surface; for the two tracked bugs they pin each host's current output as its own string, so a fix on either side goes red rather than passing unnoticed, and the pins collapse into an equality assertion once the outputs agree. +Two operations reach that identity by carrying a canonical form the specification states rather than by both hosts happening to agree. `json_stringify` emits the one form §9.7.1 pins, down to separators and number rendering, and refuses a non-finite number on both hosts instead of one refusing and the other substituting `null`. `md_render` emits the canonical Markdown §9.7.3 describes, collapsing a paragraph's soft line breaks and re-applying each container's prefix to every line of every child, which is what makes the render a fixed point and the §9.7.3 round-trip property hold on both hosts. + +Fused async preserves the underlying value whenever the two hosts' results are comparable. Only the evaluation strategy differs, which is spec-conformant — §9.5.4 says an implementation MAY evaluate `async(e)` concurrently — and any difference that remains comes from the `Http` outcome underneath rather than from `async` itself. Mandatory parity tests enforce identical results across the shared surface. ### 12.9.4 Memory Protocol @@ -685,8 +687,8 @@ The `index.html` file uses an ES module script that imports from `vera-runtime.m ### 12.9.6 Parity Testing -The browser parity test suite (`tests/test_browser.py`) runs the examples the browser target can execute — and per-binding batteries over the Map, Set, Decimal, Json, Regex and Markdown host imports — through both the Python/wasmtime runtime and the Node.js/JS-runtime. The example corpus is two explicit lists in that file rather than the whole `examples/` directory, and the two carry different oracles: the examples exporting `main` are run and compared on stdout, while the ones reached as exported functions are called with fixed arguments and compared on the returned value. The per-binding batteries compare stdout. An example is excluded from the corpus when it reads stdin interactively, when it uses a host family the browser refuses (file IO, `DB`), or when it does not compile standalone, and each exclusion is recorded beside the list with its reason. This catches drift between the two implementations everywhere the two exceptions below do not apply. The tests cover IO operations, State operations, contract violations, Markdown parsing/rendering, and browser bundle emission. +The browser parity test suite (`tests/test_browser.py`) runs the examples the browser target can execute — and per-binding batteries over the Map, Set, Decimal, Json, Regex and Markdown host imports — through both the Python/wasmtime runtime and the Node.js/JS-runtime. The example corpus is two explicit lists in that file rather than the whole `examples/` directory, and the two carry different oracles: the examples exporting `main` are run and compared on stdout, while the ones reached as exported functions are called with fixed arguments and compared on the returned value. The per-binding batteries compare stdout. An example is excluded from the corpus when it reads stdin interactively, when it uses a host family the browser refuses (file IO, `DB`), or when it does not compile standalone, and each exclusion is recorded beside the list with its reason. This catches drift between the two implementations across the whole shared surface. The tests cover IO operations, State operations, contract violations, Markdown parsing/rendering, and browser bundle emission. -The two operations where the hosts already disagree — `json_stringify` ([#1293](https://github.com/aallan/vera/issues/1293)) and `md_render` ([#1294](https://github.com/aallan/vera/issues/1294)), §12.9.3 — are the exception to the equality assertion: each runtime's current output is pinned as its own string, so the divergence cannot widen unnoticed and a fix on either side goes red. The browser stubs are covered on two different shapes: `IO.read_file` and `IO.write_file` get the same per-host pinning, running both runtimes against a path that is really readable or writable so the native `Ok` and the browser `Err` are each asserted, while `IO.read_char` is checked in the browser alone — that the module links and the stub's `Err` arm is taken — with no native side to compare against. +The two operations that carry a canonical form (§12.9.3) are tested three ways rather than by equality alone, because two hosts agreeing on a wrong answer would satisfy equality: each case asserts cross-host equality, the expected string written out, and — for `md_render` — stability under re-render, which is the observable form of §9.7.3's round-trip property. `json_stringify`'s number rendering is additionally checked differentially against a real `JSON.stringify` over a sample of doubles drawn from raw bit patterns, since the reference host renders numbers itself rather than delegating. Failures that are contracts rather than values are asserted two-sidedly: a non-finite `JNumber` must make the call fail on both hosts **and** produce no output, so a host that emitted `null` before failing could not read as a pass. The browser stubs are covered on two different shapes: `IO.read_file` and `IO.write_file` get per-host pinning, running both runtimes against a path that is really readable or writable so the native `Ok` and the browser `Err` are each asserted, while `IO.read_char` is checked in the browser alone — that the module links and the stub's `Err` arm is taken — with no native side to compare against. Pre-commit hooks trigger parity tests on any change to the host binding surface (`vera/browser/`, `vera/codegen/api.py`, `vera/wasm/markdown.py`, `vera/markdown.py`). CI runs the full parity suite on every PR. diff --git a/tests/test_browser.py b/tests/test_browser.py index 54ce39d3..92d7e95a 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -3923,14 +3923,13 @@ class TestBrowserMarkdownNesting349: run when a heading or fence is nested inside another block. ``examples/markdown.vera`` is flat, so none of them ever ran. - ``md_render`` diverges on exactly this input — see - :class:`TestBrowserMarkdownRenderParity349` — so the rendered - prefix is asserted browser-side only. The three parse-side fields - behind it are host-agnostic, and that is *checked* rather than - asserted in prose: the same module runs under both runtimes and the - trailing fields are compared. It is the claim #1294 rests on when - it scopes the defect to the renderer, so it needs a differential, - not a docstring. + ``md_render`` used to diverge on exactly this input (#1294), so the + rendered prefix was asserted browser-side only. It now agrees, and + the whole string is compared across the two runtimes. The three + parse-side fields are still compared separately as well: they were + the control that scoped #1294 to the renderer, and keeping them + named means a future renderer regression cannot be mistaken for a + parser one. """ # A blockquote wrapping an h2 and a fenced block, so the recursive @@ -3955,121 +3954,261 @@ class TestBrowserMarkdownNesting349: def test_nested_walks_and_list_continuations(self, tmp_path: Path) -> None: native, browser = _both_stdouts(self._SRC, tmp_path, "md_nesting") + # Whole string, renderer included, since #1294 closed. + assert browser == native rendered, has_h2, has_py, n_blocks = browser.rsplit("|", 3) - # The parse-side fields must be byte-identical across the two - # runtimes; only the rendered prefix is allowed to diverge. + # The parse-side fields kept as a named control: they are what + # scoped #1294 to the renderer, so a future divergence can still + # be told apart from a parser one. assert native.rsplit("|", 3)[1:] == [has_h2, has_py, n_blocks] - # Continuation lines survived the per-item loop in both list kinds. - assert "continued" in rendered - assert "also one" in rendered + # Continuation lines survived the per-item loop in both list kinds + # *and* stayed inside their item, which is the renderer's half. + assert "- first continued" in rendered + assert "1. one also one" in rendered # Recursive descents found the h2 and the fence inside the quote. assert (has_h2, has_py, n_blocks) == ("true", "true", "1") +_MD_ROUND_TRIP_PRELUDE = r""" +private fn md_round_trip(@String -> @String) + requires(true) ensures(true) effects(pure) +{ + match md_parse(@String.0) { + Ok(@MdBlock) -> md_render(@MdBlock.0), + Err(@String) -> string_concat("ERR:", @String.0) + } +} +""" + + +def _md_round_trip_src(markdown: str, *, times: int = 1) -> str: + """A ``main`` that runs ``markdown`` through ``md_render ∘ md_parse`` + ``times`` times and prints the result. + + ``markdown`` is written as it appears inside a Vera string literal — + ``\n`` as the two characters, which Vera's lexer turns into a + newline. + """ + inner = 'md_round_trip("' + markdown + '")' + for _ in range(times - 1): + inner = f"md_round_trip({inner})" + return _MD_ROUND_TRIP_PRELUDE + f""" +public fn main(@Unit -> @Unit) + requires(true) ensures(true) effects() +{{ + IO.print({inner}) +}} +""" + + +# (id, Markdown as written in a Vera string literal, canonical render). +# The four cases #1294 measured across the two hosts, in the order its +# table lists them. +_MD_RENDER_CASES = [ + ("list_continuation", r"- first\n continued\n", "- first continued"), + ("blockquote_pair", r"> a\n> b\n", "> a b"), + ( + "nested_bq_fence", + r"> ## Quoted\n>\n> ```py\n> x = 1\n> ```\n", + "> ## Quoted\n> ```py\n> x = 1\n> ```", + ), + ("plain_paragraph", r"hello\nworld\n", "hello world"), +] + +# Corpus for the §9.7.3 round-trip property. The first eight mirror +# ``TestRoundTrip`` in ``tests/test_markdown.py``, so the two runtimes +# are held to the corpus the reference renderer is already held to; the +# rest are the container/multi-line shapes #1294 was about, which that +# corpus has none of — every one of its eight entries is single-line or +# fence-only, which is exactly why a renderer that dropped container +# prefixes passed it. +_MD_ROUND_TRIP_CORPUS = [ + ("heading", r"# Hello"), + ("paragraph", r"Some text here."), + ("fence", r"```python\nprint(42)\n```"), + ("thematic_break", r"---"), + ("unordered_list", r"- item 1\n- item 2"), + ("ordered_list", r"1. first\n2. second"), + ("blockquote", r"> quoted"), + ("table", r"| A | B |\n| --- | --- |\n| 1 | 2 |"), + ("list_continuation", r"- first\n continued\n"), + ("ordered_continuation", r"1. one\n also one\n2. two\n"), + ("blockquote_pair", r"> a\n> b\n"), + ("nested_bq_fence", r"> ## Quoted\n>\n> ```py\n> x = 1\n> ```\n"), + ("plain_paragraph", r"hello\nworld\n"), + ("quoted_list", r"> - a\n> b\n> - c\n"), + ("quoted_multiline_fence", r"> ```sh\n> one\n> two\n> ```\n"), + ("list_with_fence", r"- item\n ```py\n a = 1\n b = 2\n ```\n"), + ("inlines", r"*em* and **strong** and `code` in one line\n"), + ("link_and_image", r"[text](http://example.com) then ![alt](img.png)\n"), + ( + "multi_block_document", + r"# Title\n\nIntro line\ncontinued here.\n\n> note one\n> note two" + r"\n\n- a\n b\n- c\n\n```rs\nfn one() {}\nfn two() {}\n```\n\n---\n", + ), +] + + class TestBrowserMarkdownRenderParity349: - """``md_render`` diverges from the native runtime on any multi-line - paragraph (#349 finding, tracked as #1294). - - The rule is not "list lazy continuations" — that was the first case - found, not the scope. The browser preserves a paragraph's internal - soft line breaks and does not re-apply the container prefix (``> ``, - list-item indent) on output, where the native renderer collapses - them to spaces. A bare ``hello\\nworld`` paragraph diverges too. - - Worse, the browser render is **not stable**: re-rendering its own - output moves the continuation out of its container (``> a b`` → - ``> a\\nb`` → ``> a\\n\\nb``, where ``b`` is no longer quoted), so - the browser breaks the round-trip property spec §9.7.6 states for - ``md_render`` itself, as well as §12.9.3's identical-results - requirement. The native renderer is a fixed point on every case - below. Parsing is unaffected — ``md_has_heading`` / - ``md_has_code_block`` / ``md_extract_code_blocks`` are byte-identical - across the runtimes — so the defect is scoped to the renderer in - ``vera/browser/runtime.mjs``. + """``md_render`` agrees with the reference renderer and is a fixed + point (#349 finding, tracked as #1294, closed). + + The browser used to preserve a paragraph's internal soft line breaks + and not re-apply the container prefix (``> ``, list-item indent) on + output, where the reference renderer collapses the breaks to spaces + and prefixes every line of every child. The scope was any + multi-line paragraph, not the list lazy continuation first observed, + and the render was **not stable**: re-rendering its own output moved + content out of its container (``> a b`` → ``> a\\nb`` → + ``> a\\n\\nb``), so it broke both the round-trip property spec §9.7.3 + states for ``md_render`` and §12.9.3's identical-results requirement. + On a blockquote wrapping a heading and a fenced block it destroyed + the document outright the second time round. ``examples/markdown.vera`` is flat enough to miss all of it, and - nothing else rendered Markdown under Node, so the divergence has - never been caught. - - Both sides are pinned as exact strings rather than marked ``xfail`` - for the same reason as ``TestBrowserJsonStringifyParity349``: a bare - ``xfail`` would swallow a compile failure, a dead Node harness or an - unrelated ``runtime.mjs`` regression, and this is the only - browser-side coverage of ``md_render``. + nothing else rendered Markdown under Node, which is why it went + uncaught. The battery below is therefore three-layered — cross-host + equality, an exact expected string, and stability under re-render — + because equality alone would pass two hosts that agree on the wrong + answer, and an exact string alone would pass a renderer that is + correct once and drifts on the second pass. """ - def test_lazy_continuation(self, tmp_path: Path) -> None: - src = r""" + @pytest.mark.parametrize( + ("case_id", "markdown", "expected"), + _MD_RENDER_CASES, + ids=[c[0] for c in _MD_RENDER_CASES], + ) + def test_render_matches_across_hosts( + self, case_id: str, markdown: str, expected: str, tmp_path: Path, + ) -> None: + """Byte-identical across the hosts, and equal to the reference + renderer's answer written out.""" + src = _md_round_trip_src(markdown) + assert _parity_stdout(src, tmp_path, f"md1_{case_id}") == expected + + @pytest.mark.parametrize( + ("case_id", "markdown", "expected"), + _MD_RENDER_CASES, + ids=[c[0] for c in _MD_RENDER_CASES], + ) + def test_render_is_stable_under_re_render( + self, case_id: str, markdown: str, expected: str, tmp_path: Path, + ) -> None: + """Rendering the render changes nothing, on both hosts. + + This is where the browser's defect stopped being cosmetic: the + nested blockquote's second render fragmented the fence into + three and lifted ``x = 1`` clean out of the quote, past recovery + by any subsequent parse. + """ + src = _md_round_trip_src(markdown, times=2) + assert _parity_stdout(src, tmp_path, f"md2_{case_id}") == expected + + @pytest.mark.parametrize( + ("case_id", "markdown"), + _MD_ROUND_TRIP_CORPUS, + ids=[c[0] for c in _MD_ROUND_TRIP_CORPUS], + ) + def test_round_trip_property_holds_on_both_hosts( + self, case_id: str, markdown: str, tmp_path: Path, + ) -> None: + """``md_parse(md_render(b)) == Ok(b)`` (spec §9.7.3), in its + observable form. + + Vera has no structural equality on ``MdBlock``, so the property + is exercised through the one channel a Vera program can see: + ``md_render`` composed with ``md_parse`` reaches a fixed point + after the first application. If a round trip lost or moved + structure, the second render would differ from the first — which + is exactly how the browser's blockquote failure showed up. Both + hosts must reach the *same* fixed point, so this is a parity + assertion as well as a property one. + """ + once = _parity_stdout( + _md_round_trip_src(markdown), tmp_path, f"mdrt1_{case_id}", + ) + twice = _parity_stdout( + _md_round_trip_src(markdown, times=2), tmp_path, + f"mdrt2_{case_id}", + ) + assert twice == once + assert not once.startswith("ERR:"), once + + +class TestBrowserMarkdownRenderConstructedAdt1294: + """``md_render`` on ADTs a Vera program *built*, not parsed (#1294). + + Every other Markdown case in this file reaches the renderer through + ``md_parse``, so it can only exercise the shapes the parser happens + to produce. ``MdBlock`` and ``MdInline`` are ordinary prelude ADTs + a program can construct directly, and those values reach the same + host import — so a renderer rule that only the parser never triggers + is still reachable, and still has to agree across the two hosts. + """ + + def test_code_span_containing_a_backtick(self, tmp_path: Path) -> None: + """A code span holding a backtick needs the longer fence. + + Neither parser produces one — both stop a span at the first + closing backtick — so this rule is reachable only from a + constructed ADT, which is exactly why the browser renderer was + missing it while every parse-driven test passed. Wrapping it in + a single backtick pair would re-parse as an empty span followed + by stray text, so the round trip is checked too. + """ + src = """ public fn main(@Unit -> @Unit) requires(true) ensures(true) effects() { - match md_parse("- first\n continued\n") { - Ok(@MdBlock) -> IO.print(md_render(@MdBlock.0)), - Err(@String) -> IO.print(string_concat("ERR:", @String.0)) - } + IO.print(md_render(MdDocument([MdParagraph([MdCode("a`b")])]))) } """ - native, browser = _both_stdouts(src, tmp_path, "md_parity") - assert native == "- first continued" - # Known divergence, deliberately not fixed on a tests-only branch: - # the browser's parseBlocks keeps the continuation as its own line. - assert browser == "- first\ncontinued" - - def test_blockquote_fence_render_is_destructive(self, tmp_path: Path) -> None: - """The nesting battery's blockquote — an h2 and a fenced block - inside ``> `` — pinned end to end, because this is the case where - the instability stops being cosmetic and destroys the document. - - The battery above computes this render but only substring-asserts - it, so the exact strings were unpinned. Rendering once already - strips the ``> `` from the fence's contents in the browser; - rendering *that* output again fragments the fence into three and - lifts ``x = 1`` clean out of the quote, at which point no - subsequent parse can recover the original document. The native - renderer returns the same bytes both times. - - A renderer fix moves at least one of these pins, which is the - intent: it must go red and be updated deliberately rather than - drifting silently. + assert _parity_stdout(src, tmp_path, "md_code_tick") == "`` a`b ``" + + def test_multi_line_code_block_inside_a_blockquote( + self, tmp_path: Path, + ) -> None: + """Every line of a container's child carries the prefix. + + The single case that a first-line-only prefix cannot fake, and + the one whose second render destroyed the document. Built + directly so the assertion is about the renderer alone. """ - one = r""" + src = """ public fn main(@Unit -> @Unit) requires(true) ensures(true) effects() { - match md_parse("> ## Quoted\n>\n> ```py\n> x = 1\n> ```\n") { - Ok(@MdBlock) -> IO.print(md_render(@MdBlock.0)), - Err(@String) -> IO.print(string_concat("ERR:", @String.0)) - } + IO.print(md_render(MdDocument([MdBlockQuote([ + MdCodeBlock("sh", "one\\ntwo\\nthree") + ])]))) } """ - native1, browser1 = _both_stdouts(one, tmp_path, "md_bq_once") - assert native1 == "> ## Quoted\n> ```py\n> x = 1\n> ```" - # The fence's contents lose the blockquote prefix in the browser. - assert browser1 == "> ## Quoted\n> ```py\nx = 1\n> ```" + assert _parity_stdout(src, tmp_path, "md_bq_fence_adt") == ( + "> ```sh\n> one\n> two\n> three\n> ```" + ) - # Render 2: md_render(md_parse(md_render(md_parse(src)))). - two = r""" + def test_blank_line_between_blocks_inside_a_blockquote( + self, tmp_path: Path, + ) -> None: + """The separator between a document's blocks is itself quoted. + + A blockquote wrapping two paragraphs renders the gap as a bare + ``>``, not as an empty line — an empty line would end the quote + on re-parse and split one blockquote into two. + """ + src = """ public fn main(@Unit -> @Unit) requires(true) ensures(true) effects() { - match md_parse("> ## Quoted\n>\n> ```py\n> x = 1\n> ```\n") { - Ok(@MdBlock) -> match md_parse(md_render(@MdBlock.0)) { - Ok(@MdBlock) -> IO.print(md_render(@MdBlock.0)), - Err(@String) -> IO.print(string_concat("ERR2:", @String.0)) - }, - Err(@String) -> IO.print(string_concat("ERR:", @String.0)) - } + IO.print(md_render(MdDocument([MdBlockQuote([ + MdDocument([ + MdParagraph([MdText("first")]), + MdParagraph([MdText("second")]) + ]) + ])]))) } """ - native2, browser2 = _both_stdouts(two, tmp_path, "md_bq_twice") - # Native is a fixed point — re-rendering changes nothing. - assert native2 == native1 - # The browser is not: the fence fragments and `x = 1` escapes the - # blockquote entirely. - assert browser2 == ( - "> ## Quoted\n> ```py\n\n> ```\n\nx = 1\n\n> ```\n\n> ```" + assert _parity_stdout(src, tmp_path, "md_bq_blank_adt") == ( + "> first\n>\n> second" ) - # Stated as a relation as well as two literals, so that updating - # both pins to one value — which is what a *partial* fix looks - # like — cannot quietly assert a stability the browser lacks. - assert browser2 != browser1 diff --git a/vera/README.md b/vera/README.md index fa8c184c..d471d0ce 100644 --- a/vera/README.md +++ b/vera/README.md @@ -599,7 +599,7 @@ The WASM import interface is the portability contract: the compiled `.wasm` bina **GC reachability discipline (JS host side):** JS host functions that allocate multiple WASM heap blocks and hold intermediates in JS locals must root those intermediates on the shadow stack — otherwise EAGER_GC (and, under pressure, normal GC) reclaims them mid-walk. The runtime exports two helpers: `gcShadowPush(ptr)` writes a pointer to `$gc_sp` and advances it (throws if `$gc_sp` / `$gc_stack_limit` aren't exported, since that means the module was built without GC support but is calling allocators that can trigger GC), and `gcGuard(fn)` saves `$gc_sp` at entry and restores it on exit (success or exception). This is the browser parallel of the CLI-side `_ShadowGuard` context manager added in v0.0.158 (#692). The walkers `writeJson` / `writeHtml` and the parsers `json_parse` / `html_parse` wrap their bodies in `gcGuard` and push intermediates (`arrPtr`, `wrapperPtr`, `jsonPtr`) as soon as each is allocated — see `runtime.mjs` for the canonical pattern. Without this, `Map` / `Set` and similar heap-pointer-keyed collections drop values under GC pressure (#708). -**Parity enforcement:** `tests/test_browser.py` runs the examples the browser target can execute — two explicit lists in that file, not the whole `examples/` directory, since an example that reads stdin interactively, uses a refused host family (file IO, `DB`), or does not compile standalone cannot be compared — plus per-binding batteries over the Map/Set/Decimal/Json/Regex/Markdown host imports, through both Python/wasmtime and Node.js/JS-runtime. The two example lists carry different oracles: the examples exporting `main` are run and compared on stdout, while the ones reached as exported functions are called with fixed arguments and compared on the returned value. The per-binding batteries compare stdout. Two cases are the exception, and neither is deliberate: `json_stringify` ([#1293](https://github.com/aallan/vera/issues/1293)) and `md_render` ([#1294](https://github.com/aallan/vera/issues/1294)) are tracked bugs where the hosts disagree, so each runtime's current output is pinned as its own string rather than compared — a fix on either side goes red until the pins are collapsed. The browser stubs are covered on two different shapes: `IO.read_file` and `IO.write_file` get the same per-host pinning, run through both runtimes against a path that really is readable or writable so the native `Ok` and the browser `Err` are each asserted (a missing file or an unwritable directory would `Err` on both sides and prove nothing), while `IO.read_char` is exercised in Node alone — the module links and the stub's `Err` arm returns `0` — with no native run to compare against. `Inference` and `DB` return `Err` from every browser operation, which is a deliberate platform boundary — the credentials they need would be readable from page source — rather than a divergence awaiting a fix. Pre-commit hooks and CI trigger these tests on any change to the host binding surface. +**Parity enforcement:** `tests/test_browser.py` runs the examples the browser target can execute — two explicit lists in that file, not the whole `examples/` directory, since an example that reads stdin interactively, uses a refused host family (file IO, `DB`), or does not compile standalone cannot be compared — plus per-binding batteries over the Map/Set/Decimal/Json/Regex/Markdown host imports, through both Python/wasmtime and Node.js/JS-runtime. The two example lists carry different oracles: the examples exporting `main` are run and compared on stdout, while the ones reached as exported functions are called with fixed arguments and compared on the returned value. The per-binding batteries compare stdout. `json_stringify` and `md_render` are compared the same way and additionally against the canonical form the specification states for each (§9.7.1, §9.7.3), because cross-host equality alone would be satisfied by two hosts agreeing on a wrong answer; `md_render` is also asserted stable under re-render, and `json_stringify`'s number rendering is checked differentially against a real `JSON.stringify`. The browser stubs are covered on two different shapes: `IO.read_file` and `IO.write_file` get the same per-host pinning, run through both runtimes against a path that really is readable or writable so the native `Ok` and the browser `Err` are each asserted (a missing file or an unwritable directory would `Err` on both sides and prove nothing), while `IO.read_char` is exercised in Node alone — the module links and the stub's `Err` arm returns `0` — with no native run to compare against. `Inference` and `DB` return `Err` from every browser operation, which is a deliberate platform boundary — the credentials they need would be readable from page source — rather than a divergence awaiting a fix. Pre-commit hooks and CI trigger these tests on any change to the host binding surface. `browser/emit.py` provides `emit_browser_bundle()` for the `vera compile --target browser` CLI command, which produces a ready-to-serve directory (module.wasm + vera-runtime.mjs + index.html). @@ -752,7 +752,7 @@ The `ERROR_CODES` dict in `errors.py` maps every code to a short description (15 ## Test Suite -Testing spans a **pytest suite** of 10,648 tests across 164 files — compiler-internals unit tests plus a **conformance suite** (216 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. +Testing spans a **pytest suite** of 10,676 tests across 164 files — compiler-internals unit tests plus a **conformance suite** (216 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. See **[TESTING.md](../TESTING.md)** for the comprehensive testing reference -- test file table, conformance suite details, compiler code coverage, language feature coverage, helper conventions, validation scripts, CI pipeline, and guidelines for adding tests. diff --git a/vera/browser/runtime.mjs b/vera/browser/runtime.mjs index 50f1d963..2919172d 100644 --- a/vera/browser/runtime.mjs +++ b/vera/browser/runtime.mjs @@ -714,7 +714,13 @@ function parseBlocks(text) { i++; } if (paraLines.length > 0) { - blocks.push(new MdParagraph(parseInlines(paraLines.join('\n')))); + // #1294: joined with a space, not a newline. Spec §9.7.3 excludes + // hard and soft line breaks from the ADT — "collapsed into + // paragraph text" — so a paragraph's internal breaks have to go + // somewhere at parse time or they survive into MdText, where no + // renderer can tell them from text the author wrote. This is what + // `" ".join(para_lines)` does in vera/markdown.py. + blocks.push(new MdParagraph(parseInlines(paraLines.join(' ')))); } } return blocks; @@ -737,7 +743,12 @@ function parseMarkdown(text) { function renderInline(node) { switch (node.tag) { case 'MdText': return node.text; - case 'MdCode': return '`' + node.text + '`'; + case 'MdCode': + // A span containing a backtick needs a longer fence and padding + // spaces, or it re-parses as a shorter span plus stray text. + return node.text.includes('`') + ? '`` ' + node.text + ' ``' + : '`' + node.text + '`'; case 'MdEmph': return '*' + node.children.map(renderInline).join('') + '*'; case 'MdStrong': return '**' + node.children.map(renderInline).join('') + '**'; case 'MdLink': return '[' + node.children.map(renderInline).join('') + '](' + node.url + ')'; @@ -746,44 +757,77 @@ function renderInline(node) { } } -function renderBlock(node, indent = '') { +/** + * Render a block to an array of LINES, mirroring `_render_block` in + * vera/markdown.py. + * + * #1294: the previous version returned one string and threaded a prefix + * down as an `indent` argument, which a container could only apply to + * the *first* line of each child — a fenced block inside a blockquote + * lost the `> ` on its body, and re-rendering that output moved the + * body out of the quote. Lines are the unit a container prefixes, so + * they are the unit this returns: every caller re-applies its own + * prefix to every line it receives, which is what makes the render a + * fixed point. + */ +function renderBlockLines(node) { switch (node.tag) { case 'MdParagraph': - return indent + node.children.map(renderInline).join('') + '\n'; + return [node.children.map(renderInline).join('')]; case 'MdHeading': - return indent + '#'.repeat(node.level) + ' ' + node.children.map(renderInline).join('') + '\n'; + return ['#'.repeat(node.level) + ' ' + node.children.map(renderInline).join('')]; case 'MdCodeBlock': - return indent + '```' + node.lang + '\n' + node.code + '\n' + indent + '```\n'; - case 'MdBlockQuote': - return node.children.map(c => renderBlock(c, indent + '> ')).join(''); + return ['```' + node.lang, ...node.code.split('\n'), '```']; + case 'MdBlockQuote': { + const out = []; + for (const child of node.children) { + for (const line of renderBlockLines(child)) { + out.push(line ? '> ' + line : '>'); + } + } + return out; + } case 'MdList': { - return node.items.map((item, idx) => { - const prefix = node.ordered ? `${idx + 1}. ` : '- '; - return item.map((b, bi) => (bi === 0 ? indent + prefix : indent + ' ') + renderBlock(b).trimStart()).join(''); - }).join(''); + const out = []; + node.items.forEach((item, idx) => { + const marker = node.ordered ? `${idx + 1}.` : '-'; + const indent = ' '.repeat(marker.length + 1); + const itemLines = []; + for (const child of item) itemLines.push(...renderBlockLines(child)); + itemLines.forEach((line, j) => { + out.push(j === 0 ? marker + ' ' + line : indent + line); + }); + }); + return out; } case 'MdThematicBreak': - return indent + '---\n'; + return ['---']; case 'MdTable': { - if (node.rows.length === 0) return ''; - const header = '| ' + node.rows[0].map(cells => cells.map(renderInline).join('')).join(' | ') + ' |\n'; - const sep = '| ' + node.rows[0].map(() => '---').join(' | ') + ' |\n'; - const body = node.rows.slice(1).map(row => - '| ' + row.map(cells => cells.map(renderInline).join('')).join(' | ') + ' |\n' - ).join(''); - return indent + header + indent + sep + body; - } - case 'MdDocument': - return node.children.map(c => renderBlock(c, indent)).join('\n'); + if (node.rows.length === 0) return []; + const cell = cells => cells.map(renderInline).join(''); + const out = ['| ' + node.rows[0].map(cell).join(' | ') + ' |']; + out.push('| ' + node.rows[0].map(() => '---').join(' | ') + ' |'); + for (const row of node.rows.slice(1)) { + out.push('| ' + row.map(cell).join(' | ') + ' |'); + } + return out; + } + case 'MdDocument': { + const out = []; + node.children.forEach((child, i) => { + if (i > 0) out.push(''); + out.push(...renderBlockLines(child)); + }); + return out; + } default: - return ''; + return []; } } function renderMarkdown(doc) { // Match Python's "\n".join(lines) — no trailing newline. - const raw = renderBlock(doc); - return raw.endsWith('\n') ? raw.slice(0, -1) : raw; + return renderBlockLines(doc).join('\n'); } // -- Query helpers -- From dc1184f4ded96b2cc69d5ca57bd67ba7fcecb678 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 00:44:59 +0100 Subject: [PATCH 07/69] Separate and keep a block quote's children, on both runtimes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adversarial round found the REFERENCE renderer wrong, not just the browser one. _render_block's blockquote arm emitted no separator between children, so MdBlockQuote([Para, Para]) — the shape md_parse builds from `> a\n>\n> b` — rendered as two adjacent quoted lines and read back as ONE paragraph. Structure lost silently, on both hosts, and the round-trip property spec §9.7.3 states did not hold for it; the fixed-point sentence the previous commit added to §9.7.3 asserted it away. A quote with no children was the same defect one size down: it rendered as no lines at all, so a `>` vanished on the round trip and left the enclosing separator dangling (`---\n>` came back as `---`). Both arms now mirror MdDocument's — a bare `>` between children, `>` for an empty quote — in vera/markdown.py and its runtime.mjs mirror. Two more mirrors land with them, both inside #1294's stated scope and neither closed by the renderer fix: * The browser's blockquote READER required '> ' with the space where the reference accepts ^>\s?, and had no lazy-continuation branch at all. `>no space` parsed as literal text and `> a\nb` pushed the second line out of the quote. isBlockStart() mirrors _is_block_start so the lazy branch is bounded the same way. * A code span is fenced with one backtick more than its longest internal run rather than a fixed two, padded only when the content starts or ends with a backtick. The old rule was right for one backtick and wrong for two: `` a``b `` closes on the run inside the content. The browser's inline parser scanned for the next single backtick rather than a run of equal length, so it could not read that back either; it now counts runs. A span needing three or more backticks is not representable at the start of a line, where that run is a block fence — measured and pinned rather than left as a gap. Spec §9.7.3 states all four rules. Measured on the review's 1,471-input adversarial corpus, before -> after: md_render is a fixed point on every input on both hosts (65 and 54 unstable -> 0 and 0); cross-host render divergence 489 -> 34; cross-host md_parse ADT divergence 586 -> 211. On 4,850 blank-line sections of the project's own documentation: render divergence 40 -> 11, reference round trip unstable 1 -> 0. Eleven mutations, one per facet, each confirmed to turn the battery red — two of them survived the first attempt and the tests they exposed as non-discriminating were fixed, not the mutations. The surviving md_parse divergences are a separate defect with its own measured class breakdown; KNOWN_ISSUES.md carries the row and the successor issue is filed alongside this change. Also folded in: the browser's json_stringify no longer falls back to "null" when JSON.stringify returns undefined — unreachable from readJson, but the same silent substitution #1293 removed one layer up; and the non-finite parity test asserts the whole shared sentence, taken from the reference implementation so the browser's hand-copied duplicate is held against the original, plus that neither host printed anything before failing. Refs #1294 Co-Authored-By: Claude --- CHANGELOG.md | 3 + FAQ.md | 4 +- KNOWN_ISSUES.md | 1 + README.md | 4 +- ROADMAP.md | 2 +- TESTING.md | 6 +- docs/index.html | 2 +- docs/index.md | 2 +- docs/llms-full.txt | 4 +- scripts/build_site.py | 2 +- spec/09-standard-library.md | 6 +- spec/12-runtime.md | 8 +- tests/test_browser.py | 202 ++++++++++++++++++++++++++++++------ tests/test_markdown.py | 103 ++++++++++++++++++ vera/README.md | 4 +- vera/browser/runtime.mjs | 118 +++++++++++++++++---- vera/markdown.py | 48 +++++++-- 17 files changed, 439 insertions(+), 80 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b9eac84..a104b9c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **`new(State)` reads the cell its contract names** ([#1285](https://github.com/aallan/vera/issues/1285)). `old(State)` has been keyed on the resolved cell family since [#1205](https://github.com/aallan/vera/issues/1205)/[#1209](https://github.com/aallan/vera/issues/1209); `new(State)` read the name-keyed `_effect_ops["get"]`, which holds whichever `State` the effect row registered first. Under a single-`State` row the two keyings coincide, which is why the corpus agreed; under a multi-`State` row the two sides of one `ensures` clause read different cells. `effects(, State>)` with `ensures(new(State) == false)` was check-green *and* verify-green, emitted `state_get_Int`'s i64 into the Bool comparison's `i32.eq`, and died at load with wasmtime's raw `type mismatch: expected i32, found i64`. The type mismatch is the symptom rather than the defect: where both cells share a machine width — `State` beside `State` — the module loaded and answered about the other cell, so `ensures(new(State) == old(State))` on a function that writes neither was refuted at runtime on a contract the verifier had discharged. Codegen now carries a family→getter registry populated at the declared-row registration site from the per-family `CellNames` it already computes, and `_translate_new_expr` keys on `_state_effect_family` exactly as `_translate_old_expr` does. A bare `get(())` names no family and so is right to keep reading the name-keyed registry, source-order-first-wins; a contract names one and must not. +- **A block quote separates and keeps its children, on both runtimes** ([#1294](https://github.com/aallan/vera/issues/1294) review). The **reference** renderer emitted no separator between a blockquote's children, so `MdBlockQuote([Para, Para])` — the shape `md_parse` builds from `> a\n>\n> b` — rendered as two adjacent quoted lines and read back as **one** paragraph. Structure lost silently, on both hosts, and the round-trip property spec §9.7.3 states did not hold for it. A quote with no children was the same defect one size down: it rendered as no lines at all, so a `>` in a document vanished on the round trip and left the enclosing separator dangling (`---\n>` came back as `---`). Both arms now mirror `MdDocument`'s: a bare `>` between children, and `>` for an empty quote. Two more mirrors land with them. The **browser parser**'s blockquote reader required `> ` with the space where the reference accepts `^>\s?`, and had no lazy-continuation branch at all, so `>no space` parsed as literal text and `> a\nb` pushed the second line out of the quote — both inside #1294's stated scope and neither closed by the renderer fix. And a **code span** is now fenced with one backtick more than its longest internal run rather than a fixed two, padded only when the content starts or ends with a backtick: the old rule was right for one backtick and wrong for two, since ``` `` a``b `` ``` closes on the run *inside* the content. The browser's inline parser scanned for the next single backtick rather than a run of equal length, so it could not read that back either; it now counts runs like the reference. Spec §9.7.3 states all four rules. Measured on a 1,471-input adversarial corpus: `md_render` is a fixed point on **every** input on both hosts (from 65 and 54 unstable), and cross-host render divergence falls from 489 inputs to 34; on 4,850 sections of the project's own documentation, render divergence falls from 40 to 11 and the reference round trip is a fixed point everywhere. The remaining `md_parse` divergences are a separate tracked bug, listed in `KNOWN_ISSUES.md` with a measured class-by-class breakdown. Alongside them: the browser's `json_stringify` no longer falls back to `"null"` when `JSON.stringify` returns `undefined` — unreachable from `readJson`, but the same silent substitution [#1293](https://github.com/aallan/vera/issues/1293) removed one layer up, and an unreachable branch is where a silent wrong answer survives; and the non-finite parity test now asserts the *whole* shared sentence, taken from the reference implementation so the browser's hand-copied duplicate is held against the original, plus that neither host printed anything before failing. + + - **The browser runtime's `md_render` mirrors the reference renderer, holds the round-trip property, and is a fixed point** ([#1294](https://github.com/aallan/vera/issues/1294)). It preserved a paragraph's internal soft line breaks and did not re-apply a container's prefix (`> `, list-item indent) on output, so it broke the round-trip property spec §9.7.3 states for `md_render` and, with it, §12.9.3's identical-results requirement. The scope was any multi-line paragraph, not the list lazy continuation first observed, and the render was not stable: re-rendering its own output moved content out of its container (`> a b` → `> a\nb` → `> a\n\nb`, where `b` is no longer quoted), and on a blockquote wrapping a heading and a fenced block the second render fragmented the fence into three and lifted the code clean out of the quote, past recovery by any subsequent parse. Two defects underlay it, in two different phases, and both are fixed because neither alone closes the issue. The **parser** joined a paragraph's lines with `\n` where the reference parser joins with a space: §9.7.3's design note excludes hard and soft line breaks from the ADT — "collapsed into paragraph text" — so a break that survives into `MdText` is one no renderer can tell from text the author wrote, and `md_parse` itself therefore returned different ADTs on the two hosts. The **renderer** returned one string and threaded the container prefix down as an argument, which a container could only apply to the *first* line of each child; it is now line-based, mirroring `_render_block` in `vera/markdown.py`, so every caller re-applies its own prefix to every line it receives — the property that makes the render a fixed point. A third mirror lands with them: a code span containing a backtick now renders with the longer `` `` … `` `` fence, which the reference renderer has always done and neither parser can produce, so it was reachable only from a constructed ADT. Spec §9.7.3 states both rules rather than leaving them as an implementation detail two hosts had to rediscover. `tests/test_browser.py`'s two pinned-divergence assertions collapse into parity assertions, and the battery around them is now three-layered — cross-host equality, the expected string, and stability under re-render — because equality alone passes two hosts that agree on a wrong answer and a single render passes a renderer that drifts on the second pass. The §9.7.3 round-trip property is exercised over a nineteen-case corpus: the eight the reference renderer is already held to in `tests/test_markdown.py`, every one of which is single-line or fence-only and therefore blind to exactly this defect, plus the container and multi-line shapes the bug was about. Three further cases render ADTs a Vera program *built* rather than parsed, since the parser only reaches the shapes it happens to produce. diff --git a/FAQ.md b/FAQ.md index eef9d5b7..78f0740c 100644 --- a/FAQ.md +++ b/FAQ.md @@ -170,7 +170,7 @@ vera compile --target browser examples/hello_world.vera # index.html ``` -Serve it with any HTTP server and open `index.html` — no build step, no bundler, no dependencies. The JavaScript runtime provides browser-appropriate implementations of all Vera host bindings: `IO.print` writes to the page, `IO.read_line` uses `prompt()`, and all other operations (State, contracts, JSON, Markdown) work identically to the wasmtime runtime. `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states — §9.7.1 and §9.7.3 — rather than by the two hosts happening to agree, which is what the parity suite checks them against. +Serve it with any HTTP server and open `index.html` — no build step, no bundler, no dependencies. The JavaScript runtime provides browser-appropriate implementations of all Vera host bindings: `IO.print` writes to the page, `IO.read_line` uses `prompt()`, and all other operations (State, contracts, JSON, Markdown rendering) work identically to the wasmtime runtime. `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states — §9.7.1 and §9.7.3 — rather than by the two hosts happening to agree, which is what the parity suite checks them against. `md_parse` is the exception: the two hand-written parsers still disagree on how a paragraph's plain-text runs are grouped — invisible to `md_render`, since they concatenate to the same text — and on a few block markers the §9.7.3 subset leaves open, such as a `+` bullet or a list nested more than two deep. That is a tracked bug, listed in [KNOWN_ISSUES.md](KNOWN_ISSUES.md). Two effects are refused outright rather than merely differing. `Inference` and `DB` return an explanatory `Err` from every operation in the browser, because the API key or database credential they would need is readable from page source and network traffic in client-side JavaScript. Reach them through a server-side endpoint and call it with `Http`, which does run in the browser — it is backed by `XMLHttpRequest`, not a stub. That refusal is a deliberate platform boundary; spec §9.5.5 states it for `Inference`. @@ -279,7 +279,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 10,676 tests, including a 216-program conformance suite +- 10,714 tests, including a 216-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index ca4df160..c92ce342 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -8,6 +8,7 @@ Defects in shipped compiler, runtime, or tooling behaviour — this table matche | Bug | Issue | |-----|-------| +| `md_parse` diverges between the native and browser runtimes on **211 of 1,471** adversarial inputs (14.3%) and **329 of 4,850** blank-line-separated sections of the project's own documentation (6.8%), measured by comparing the two ADTs directly rather than their renders. Nine classes, each with a one-line repro. The largest by a wide margin — 173 of the 211 — is *plain-text run grouping*: the browser emits one `MdText` per scan segment where the reference coalesces adjacent runs, so `**unclosed` is `[MdEmph([]), MdText("unclosed")]` natively and `[MdText("*"), MdText("*unclosed")]` in the browser. That class is invisible to `md_render` (the runs concatenate to the same text) but not to a Vera program that matches on the ADT, which is what makes it a §12.9.3 violation rather than a cosmetic one. The rest are render-visible: emphasis/strong scanning (`***both***` renders `**both****` natively, `***both***` in the browser); list-continuation indent width, where the reference strips exactly two (or three) characters and the browser strips all leading whitespace (`- a\n b` → `- a b` vs `- a b`); a `+` bullet, unrecognised in the browser; an `n)` ordered marker, likewise; a loose list, one list natively and two in the browser (`- a\n\n- b`); nesting past two levels, flattened in the browser (`- a\n - b\n - c`); a thematic break with internal spaces (`* * *`); and a table without a separator row (`\| a \| b \|\n- li`). Neither implementation is the specification — §9.7.3 pins the ADT, not the grammar that produces it — so closing this means choosing a parse for each class and stating it, then mirroring. Successor to [#1294](https://github.com/aallan/vera/issues/1294), which closed the `md_render` half; the parity suite pins every shape the two implementations do agree on, so the set cannot widen unnoticed. | `#TBD-SUCCESSOR` | | Codegen's `_known_fns` mirrors the flat `_fn_sigs`, which keys a name the call site **cannot see** under its bare name, so the #1284 ownership predicate answers "user-owned" at a site where the checker resolved the effect operation — one rule, two tables that disagree. Three demonstrated routes: a `private fn get` in an imported module (still reachable, so still emitted); a `public fn get` a selective import excludes; and a `where` helper of a **`forall` parent**, which keeps a bare `_fn_sigs` key beside its clone-qualified one (`helper`/`id` in `ch09_generic_where_helper.vera`) where a non-generic parent's helper does not. All three are check-green, and how the failure lands depends on the route and on the widths: the two import routes are the dangerous ones — where both cells are i64 the module loads and returns the invisible helper's value instead of the cell's (**silent wrong value**), and where the widths differ it fails to load (`type mismatch: expected i64, found i32`). The generic-`where` route is always **loud**: the bare key exists in the signature table while no bare symbol is emitted (the helper is only ever `holder$Bool$where$get`), so the call fails at WAT assembly with `unknown func: failed to find name $get` and no E-code. The checker's answer is nailed by a type oracle rather than asserted — the invisible `get` returns `@Bool` while the caller returns `@Int` from `get(())` and checks green, which is only possible if the checker typed the call from the `State` cell — and each route carries a rename control that returns the checker's value. Pre-existing, not introduced by the #1284 fix, which made the rule one function without making either table scope-accurate. Fix direction: `_known_fns` becomes the names visible in the compiling declaration's **lexical** scope — module scope is not enough, since a generic's `where` helper *is* in the module and still is not in a sibling's scope — or the flattening stops keying invisible names bare, the `mod$…` renaming `_resolve_module_call_wasm_name` already applies to shadowed generics being the precedent. Same module-namespace table family as [#1281](https://github.com/aallan/vera/issues/1281). Spec §7.4 states the language rule ("declarations first — a property of the call site's scope alone"); this row tracks codegen's violation of it. | [#1299](https://github.com/aallan/vera/issues/1299) | | A postcondition may name a `State` the function's effect row never declares, and `vera check` accepts it: `ensures(new(State) == false)` under `effects(>)` reports OK, then `vera compile` fails with **E699** — the internal-compiler-error diagnostic whose own text says the type checker should have rejected the input, which is exactly the situation. Both forms land there for the same reason (no cell of that family exists, so `old()` finds no snapshot local and, since [#1285](https://github.com/aallan/vera/issues/1285), `new()` finds no getter). Loud and never a wrong answer, so this is diagnostic quality rather than soundness — but it is a check-green program that cannot compile, reported against the compiler instead of against the program, with a bug-report request the user should not act on. Before #1285 the `new()` side was worse than loud: the name-keyed lookup found the row's other getter and silently read the wrong cell. Fix direction: validate an `OldExpr`/`NewExpr`'s effect reference against the declared row where the checker already validates the rest of the clause, one rule for both forms; `test_a_family_the_row_does_not_declare_is_loud_on_both_sides` pins today's E699 and is the test to flip. | [#1298](https://github.com/aallan/vera/issues/1298) | | Spec §1.4 says its reserved keywords MUST NOT be used as function names, and for **seventeen of them nothing enforces it**: `then`, `else`, `data`, `type`, `module`, `import`, `public`, `private`, `requires`, `ensures`, `invariant`, `decreases`, `effect`, `with`, `in`, `where` and `pure` all declare and pass `vera check` as `public fn (@Int -> @Int)`. (`handle` is an eighteenth acceptance and is the sanctioned host-entry-point carve-out, §5.2; the other eleven names — `resume`, `exists`, `forall`, `match`, `assert`, `assume`, `fn`, `let`, `if`, `true`, `false` — are refused **E153**.) They are not even traps: `private fn with(@Int -> @Int)` and its `where` / `type` / `pure` / `requires` / `import` siblings are **callable** from the same file, so the declaration resolves and runs like any other function. The comment above `_KEYWORD_FN_NAMES` in `vera/checker/registration.py` gives the opposite as the reason those names are absent from the set — that the contextual lexer "does not admit them as a function name, so no declaration reaches this checker at all" — so the omission rests on a premise the tree refutes, and neither the parser nor the checker holds the MUST. Two resolutions, and the choice is a ruling rather than a mechanical fix: extend `_KEYWORD_FN_NAMES` so the seventeen join `resume` under E153, or narrow §1.4's MUST to the names actually reserved. The `resume` precedent leans to enforcing — `resume` is likewise not a keyword token anywhere and likewise parses, and was reserved *at the checker* precisely because the parser had no keyword to refuse it with — and E153's own rationale, that a keyword names one construct and nothing else (DESIGN principle 3), does not depend on the name being a trap. Until the ruling lands, §1.4's `handle` carve-out sentence deliberately makes no enforcement claim, so the spec does not assert a MUST it cannot back. | [#1296](https://github.com/aallan/vera/issues/1296) | diff --git a/README.md b/README.md index c239a6e9..76f22252 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,7 @@ vera effects --json # list the effect and ability registry vera errors --json # list the diagnostic-code registry: E001–E702 + W001/W002 (no file needed) ``` -`vera compile --target browser` produces a self-contained bundle (wasm + JS runtime + HTML) that runs in any browser — no build step, no bundler. Mandatory parity tests ensure identical behaviour between the command-line and browser runtimes for the pure-language surface (arithmetic, ADTs, pattern matching, closures, contracts, effects-as-host-imports, etc.). Two operations on that surface reach identity by emitting a canonical form the specification states rather than by the hosts happening to agree — `json_stringify` (spec §9.7.1) and `md_render` (§9.7.3) — so their tests assert the expected string as well as cross-host equality. Distinct from that: `Inference.complete`, `DB.query` and `DB.execute` return `Err` from every browser call by definition of the target, because the credential each needs would be readable from page source — reach them through a server-side endpoint called with `Http`, which does run in the browser. The IO surface is the other documented exception: terminal Vera programs that rely on `IO.sleep` for animation pacing or ANSI escape codes for cursor control compile cleanly to `--target browser` but render the escapes as literal text and freeze the tab while sleeping — the browser target expects Vera to be the pure simulation core and JavaScript to drive timing and rendering ([SKILL.md §Browser compilation](SKILL.md#browser-compilation) has the recommended pattern). +`vera compile --target browser` produces a self-contained bundle (wasm + JS runtime + HTML) that runs in any browser — no build step, no bundler. Mandatory parity tests ensure identical behaviour between the command-line and browser runtimes for the pure-language surface (arithmetic, ADTs, pattern matching, closures, contracts, effects-as-host-imports, etc.). Two operations on that surface reach identity by emitting a canonical form the specification states rather than by the hosts happening to agree — `json_stringify` (spec §9.7.1) and `md_render` (§9.7.3) — so their tests assert the expected string as well as cross-host equality. One operation still falls short: the two hand-written `md_parse` implementations disagree on how a paragraph's plain-text runs are grouped and on a handful of block markers the §9.7.3 subset does not pin, so the parity suite covers the shapes they do agree on and the rest is a tracked bug (**[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**). Distinct from that: `Inference.complete`, `DB.query` and `DB.execute` return `Err` from every browser call by definition of the target, because the credential each needs would be readable from page source — reach them through a server-side endpoint called with `Http`, which does run in the browser. The IO surface is the other documented exception: terminal Vera programs that rely on `IO.sleep` for animation pacing or ANSI escape codes for cursor control compile cleanly to `--target browser` but render the escapes as literal text and freeze the tab while sleeping — the browser target expects Vera to be the pure simulation core and JavaScript to drive timing and rendering ([SKILL.md §Browser compilation](SKILL.md#browser-compilation) has the recommended pattern). `vera compile --target wasi-p2` emits an **experimental WASI Preview 2 target (IO and Random surface)**: a binary WebAssembly component whose host imports are implemented over WASI 0.2 interfaces, runnable by any stock wasip2 host (`wasmtime run` needs no flags and no Vera bindings). Programs using host families beyond IO/Random are rejected with a diagnostic naming the family — never silently compiled against the core target. See [spec chapter 13](spec/13-wasi.md) for the architecture, the supported surface, and the documented divergences (WASI 0.2's ok/err-only exit codes, no structured trap frames across the component boundary). With `--world server`, the same contract-verified `handle(Request -> Response)` program `vera serve` hosts natively compiles to a `wasi:http/incoming-handler` component that stock `wasmtime serve` runs unmodified — verified HTTP handlers as a portable deployment artifact (`--world server` is only valid together with `--target wasi-p2`; the CLI rejects other combinations). @@ -263,7 +263,7 @@ cp /path/to/vera/SKILL.md ~/.claude/skills/vera-language/SKILL.md ## Project status -Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 10,676 tests, 95% Python code coverage, 216 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. +Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 10,714 tests, 95% Python code coverage, 216 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. The reference compiler — parser, AST, type checker, contract verifier (Z3), WASM code generator, module system, browser runtime, and runtime contract insertion — is working. The language specification is in draft across [14 chapters](spec/). diff --git a/ROADMAP.md b/ROADMAP.md index 7e02e4d6..a089e45c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,7 +12,7 @@ Ordering derives from the design principles ([DESIGN.md](DESIGN.md)): verificati ## Where we are -10,676 tests, 216 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. +10,714 tests, 216 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. ## Stage 19 — The verification completeness sprint diff --git a/TESTING.md b/TESTING.md index b57d9ecd..38204bdb 100644 --- a/TESTING.md +++ b/TESTING.md @@ -6,7 +6,7 @@ This is the single source of truth for Vera's testing infrastructure, coverage d | Metric | Value | |--------|-------| -| **Tests** | 10,676 across 164 files (~152,000 lines of test code; 10,508 passed + 26 stress, 142 skipped) | +| **Tests** | 10,714 across 164 files (~152,000 lines of test code; 10,546 passed + 26 stress, 142 skipped) | | **Compiler code coverage** | 95% Python, 87% JavaScript (CI minimum: 80%) | | **Conformance programs** | 216 programs across 9 spec chapters, validating every language feature | | **Example programs** | 42, all validated through `vera check` + `vera verify` | @@ -194,9 +194,9 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_tester.py` | 17 | 445 | Contract-driven testing: tier classification, input generation, test execution, skip message content | | `test_tester_artifacts.py` | 1 | 89 | `vera test` compiles through the same artifact tables as the other CLI doors: the tester-compiled WAT for a tuple-component widening carries the widen guard (without the shared artifact tables, `cmd_test` emits no guard while the verifier claims Tier-3 runtime-guarded — a verifier↔codegen divergence) | | `test_tester_coverage.py` | 49 | 1,395 | Tester coverage gaps: String/Float64/ADT parameter input generation, Bool/Byte parameters, unsatisfiable preconditions, type expression edge cases, FP model-value extraction (NaN/Inf/signed-zero, #797), the #1208 naming pin (the threaded alias environment canonicalizes a type-argument alias), and the #1216 resolution set — an alias-typed parameter is trialed rather than skipped, its alias-spelled `requires` constrains the generated inputs, a refined alias reaches Z3 with its predicate so no trial violates codegen's entry guard, a `forall` variable shadowing a same-named module alias still resolves to an unsupported type variable, and a parameter that resolves to a non-encodable type still skips with that type named; plus the #1229 set — an UNTRANSLATABLE input constraint skips the function naming the blocking conjunct instead of scoring the resulting entry-guard trap as a contract failure, on the reported `string_length` repro, on a mixed clause (only the untranslatable conjunct is named), on a quantified precondition and on a refined parameter's predicate, with a Tier-3 control that must still be tested so a skip taxonomy firing on everything cannot pass | -| `test_markdown.py` | 59 | 393 | Markdown parser: block/inline parsing, rendering, round-trips, edge cases | +| `test_markdown.py` | 78 | 496 | Markdown parser: block/inline parsing, rendering, round-trips, edge cases | | `test_lsp.py` | 146 | 2470 | LSP transport + coordinate layer (#222 Phase C) and language features (#222 Phase D): parametrized code-point↔UTF-16 goldens incl. astral-plane fixtures and surrogate-pair snapping, Span (1-based, exclusive-end) and SourceLocation (0-based col) → LSP Range conversions, point→token-range widening, DocumentStore open/change/close + index invalidation, an in-process handler-drive test, and one stdio end-to-end round-trip against the real `vera lsp` subprocess (initialize → didOpen → shutdown → exit) pinning serverInfo + textDocumentSync capabilities; plus the Phase D feature suite — parse-error single-diagnostic path, type-error verification short-circuit, tier=3 in E520 diagnostic data, per-function tier Hint synthesis (and its suppression for functions with violated obligations), smallest-enclosing-span hover, De Bruijn slot goto (most-recent-parameter jump, out-of-range None, off-slot None, and the #1208 keying pins: a parameterised reference resolves, an alias-spelled parameter is reachable from a canonically-spelled reference, and a `forall` variable shadowing a module alias lands on the right parameter), and typed-hole completion (inside/after hole, away-from-hole None); plus the Phase E speculativeEdit suite — identical-text all-unchanged, breaking edit surfaces newly_undischarged (violated nat_sub) with canonical state untouched, strengthening edit surfaces newly_discharged, parse/type errors report ok:false, deleted functions report removed, proof_delta purity; plus the Phase F1 proposeEdit suite — the apply gate (clean and strengthening edits apply, breaking and non-compiling edits refuse), force overriding both gates with the delta still reported, wiring against a structural fake server (apply round-trip with exact full-document replacement range, refuse touches no canonical state, unopened-URI clamp sentinel), and full-document-range goldens (trailing-newline virtual line, UTF-16 end column); plus the Phase F2 strengthenContract suite — splice goldens (first-clause-only replacement with byte-identical remainder, ensures variant, unknown-fn None), the call-site audit pin (tightened precondition refused with newly_undischarged call_pre items, canonical state untouched), provable-ensures strengthening applies, and the three splice-target refusal paths (no analysis, unparseable document, unknown function); plus the Phase F3 addEffect suite — transitive-caller closure goldens (diamond in declaration order, leaf, unknown-fn None, recursion appears once), handler bounding (#725: a caller discharging the effect around its only call site drops out of the closure and is not rewritten, while a second unhandled path, a call in a handler clause, a handler for another effect, a handler naming a different instance of the same effect (`handle[State]` against a `State` propagation, which the checker does not discharge — end-to-end that caller must still be rewritten and the candidate must still apply, with the matching `State` propagation against the same fixture as the positive control that this handler key does prune something), and a bare `where`-helper call all keep it in, as does a refinement type argument at either depth (`Exn<{ @Int \| p }>` and `Exn>` render as their bare base type but discharge nothing of `Exn` — also pinned end-to-end), while an unparameterised `handle[IO]` does bound an `IO` propagation, a whitespace-spelled `State< Int >` request still bounds, and nesting bounds in either order (a matching handler inside a foreign one, a foreign one inside a matching one) — plus a key-level pin that `handle[Mod.IO]` keeps its module, and the effect-less query pinned handler-unaware; plus the two boundary pins the review added — a call in the handler's STATE INITIALISER keeps its edge, since the initialiser is evaluated in the enclosing scope before the handler is installed (pinned beside the E125 the checker raises there against a `pure` caller, with the identical call in the handler body clean as the contrast), and an alias-spelled handler does not bound a `State` propagation though the checker discharges it (`handle[State]` with `type MyAlias = Int` — the spelling comparison's under-prune, #1292, with the alias-spelled request as the control that does prune)), effect-row rewrite goldens (pure to singleton set, source-preserving append, already-present None, base-name identity blocking State next to State), diamond propagation applying one multi-site candidate with the bystander untouched, mixed append/replace rows with already-satisfied callers skipped, the fully-satisfied no-op shape, and the two refusal paths; plus the #728 instruction-contract suite — the LSP message carries description, rationale, and the Fix: paragraph (also pinning single E501 emission at the LSP surface), and a bare diagnostic maps to the description alone | -| `test_browser.py` | 255 | 4,214 | Browser parity: Python/wasmtime vs Node.js/JS-runtime output equivalence across IO, State, contracts, Markdown, Regex, and the examples the browser target can execute (two explicit lists in the file, not the whole `examples/` directory — interactive stdin, file IO, `DB` and the non-standalone `modules` example are excluded with their reasons recorded); plus the #349 `runtime.mjs` coverage battery — per-value-type and per-key-type Map variants, per-element-type Set variants, cold `Decimal` branches (exact-zero sign, negative-shift division, `decRoundPlaces` special cases, non-finite storage), `readJson`/`json_stringify` across every Json ADT tag, the Regex/Json `Result.Err` arms, and nested-Markdown walks. Two operations carry a canonical form the specification states rather than merely agreeing across the hosts, so their batteries assert more than equality: `json_stringify` (spec §9.7.1) pins the expected string on every Json ADT tag and on the number-rendering boundaries, checks three-pass idempotence, checks that a non-finite `JNumber` fails on both hosts *and* prints nothing, and checks the reference host's own number rendering differentially against a real `JSON.stringify` over doubles drawn from raw bit patterns; `md_render` (§9.7.3) pins the expected render, re-renders it to prove the fixed point, and runs the round-trip property over a corpus that includes the container and multi-line shapes a flat corpus misses. Both were host-divergent until v0.1.12 ([#1293](https://github.com/aallan/vera/issues/1293), [#1294](https://github.com/aallan/vera/issues/1294)) | +| `test_browser.py` | 274 | 4,356 | Browser parity: Python/wasmtime vs Node.js/JS-runtime output equivalence across IO, State, contracts, Markdown, Regex, and the examples the browser target can execute (two explicit lists in the file, not the whole `examples/` directory — interactive stdin, file IO, `DB` and the non-standalone `modules` example are excluded with their reasons recorded); plus the #349 `runtime.mjs` coverage battery — per-value-type and per-key-type Map variants, per-element-type Set variants, cold `Decimal` branches (exact-zero sign, negative-shift division, `decRoundPlaces` special cases, non-finite storage), `readJson`/`json_stringify` across every Json ADT tag, the Regex/Json `Result.Err` arms, and nested-Markdown walks. Two operations carry a canonical form the specification states rather than merely agreeing across the hosts, so their batteries assert more than equality: `json_stringify` (spec §9.7.1) pins the expected string on every Json ADT tag and on the number-rendering boundaries, checks three-pass idempotence, checks that a non-finite `JNumber` fails on both hosts *and* prints nothing, and checks the reference host's own number rendering differentially against a real `JSON.stringify` over doubles drawn from raw bit patterns; `md_render` (§9.7.3) pins the expected render, re-renders it to prove the fixed point, runs the round-trip property over a corpus carrying the container and multi-line shapes a flat corpus misses, and renders `MdBlock` values the test builds directly, since several renderer rules — a container's child separator, an empty container, a code span wider than one backtick — are unreachable through `md_parse`. `md_parse` itself is not yet at parity and its remaining divergence classes are a tracked bug (**[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**); the suite pins the shapes the two implementations agree on | | `test_conformance.py` | 1080 | 125 | Parametrized conformance suite: parse, check, verify, run, format idempotency across 216 programs | | `test_prelude.py` | 29 | 585 | Prelude injection: Option/Result/array operation detection, combinator shadowing, type aliases, the reserved namespace every injected alias declaration lives in — checked against the checker's own E154 regex rather than a second spelling of the rule, since an alias the prelude injects outside it is one codegen resolves and the checker leaves opaque (#1184/#1221) — end-to-end compilation | | `test_checker_apply_fn.py` | 18 | 455 | #854 — `apply_fn` as a checker special form: zero-warning pins (API + CLI `--json` + closures.vera), E201 arity / E202 type / non-function-first-arg errors, E122/E125 effect-row enforcement for applied fn values, E151 redefinition rejection, variadic two-param application, prelude combinator regression pins | diff --git a/docs/index.html b/docs/index.html index 411bf2be..e9466fc1 100644 --- a/docs/index.html +++ b/docs/index.html @@ -576,7 +576,7 @@

Browser

runtime.mjs index.html
-

Self-contained — no bundler. Serve with any HTTP server (python -m http.server). IO.print writes to the page; every other operation the browser target supports works identically to the CLI. json_stringify and md_render reach that identity by emitting a canonical form the specification states (§9.7.1, §9.7.3) rather than by the hosts happening to agree, so parity tests check them against that form as well as against each other, on every PR. Note: Inference.complete and every DB operation return an error in the browser — a deliberate platform boundary, since the credentials they need would be readable from page source; reach them through a server-side proxy via Http.

+

Self-contained — no bundler. Serve with any HTTP server (python -m http.server). IO.print writes to the page; every other operation the browser target supports works identically to the CLI, apart from md_parse, whose two hand-written implementations still disagree on a few shapes the §9.7.3 subset does not pin (a tracked bug). json_stringify and md_render reach that identity by emitting a canonical form the specification states (§9.7.1, §9.7.3) rather than by the hosts happening to agree, so parity tests check them against that form as well as against each other, on every PR. Note: Inference.complete and every DB operation return an error in the browser — a deliberate platform boundary, since the credentials they need would be readable from page source; reach them through a server-side proxy via Http.

WASI components

diff --git a/docs/index.md b/docs/index.md index db7f4b57..5106340a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -217,7 +217,7 @@ Browser bundle: examples/hello_world_browser/ index.html ``` -Self-contained — no bundler. Serve with any HTTP server (`python -m http.server`). `IO.print` writes to the page; every other operation the browser target supports works identically to the CLI. `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states (§9.7.1, §9.7.3) rather than by the hosts happening to agree, so parity tests check them against that form as well as against each other, on every PR. *Note: `Inference.complete` and every `DB` operation return an error in the browser — a deliberate platform boundary, since the credentials they need would be readable from page source; reach them through a server-side proxy via `Http`.* +Self-contained — no bundler. Serve with any HTTP server (`python -m http.server`). `IO.print` writes to the page; every other operation the browser target supports works identically to the CLI, apart from `md_parse`, whose two hand-written implementations still disagree on a few shapes the §9.7.3 subset does not pin (a tracked bug). `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states (§9.7.1, §9.7.3) rather than by the hosts happening to agree, so parity tests check them against that form as well as against each other, on every PR. *Note: `Inference.complete` and every `DB` operation return an error in the browser — a deliberate platform boundary, since the credentials they need would be readable from page source; reach them through a server-side proxy via `Http`.* ### WASI components diff --git a/docs/llms-full.txt b/docs/llms-full.txt index f4e78303..a2ff350f 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -3147,7 +3147,7 @@ vera compile --target browser examples/hello_world.vera # index.html ``` -Serve it with any HTTP server and open `index.html` — no build step, no bundler, no dependencies. The JavaScript runtime provides browser-appropriate implementations of all Vera host bindings: `IO.print` writes to the page, `IO.read_line` uses `prompt()`, and all other operations (State, contracts, JSON, Markdown) work identically to the wasmtime runtime. `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states — §9.7.1 and §9.7.3 — rather than by the two hosts happening to agree, which is what the parity suite checks them against. +Serve it with any HTTP server and open `index.html` — no build step, no bundler, no dependencies. The JavaScript runtime provides browser-appropriate implementations of all Vera host bindings: `IO.print` writes to the page, `IO.read_line` uses `prompt()`, and all other operations (State, contracts, JSON, Markdown rendering) work identically to the wasmtime runtime. `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states — §9.7.1 and §9.7.3 — rather than by the two hosts happening to agree, which is what the parity suite checks them against. `md_parse` is the exception: the two hand-written parsers still disagree on how a paragraph's plain-text runs are grouped — invisible to `md_render`, since they concatenate to the same text — and on a few block markers the §9.7.3 subset leaves open, such as a `+` bullet or a list nested more than two deep. That is a tracked bug, listed in [KNOWN_ISSUES.md](https://github.com/aallan/vera/blob/main/KNOWN_ISSUES.md). Two effects are refused outright rather than merely differing. `Inference` and `DB` return an explanatory `Err` from every operation in the browser, because the API key or database credential they would need is readable from page source and network traffic in client-side JavaScript. Reach them through a server-side endpoint and call it with `Http`, which does run in the browser — it is backed by `XMLHttpRequest`, not a stub. That refusal is a deliberate platform boundary; spec §9.5.5 states it for `Inference`. @@ -3256,7 +3256,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 10,676 tests, including a 216-program conformance suite +- 10,714 tests, including a 216-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/scripts/build_site.py b/scripts/build_site.py index 6bab0545..d522f111 100644 --- a/scripts/build_site.py +++ b/scripts/build_site.py @@ -615,7 +615,7 @@ def build_index_md(version: str) -> str: index.html ``` -Self-contained — no bundler. Serve with any HTTP server (`python -m http.server`). `IO.print` writes to the page; every other operation the browser target supports works identically to the CLI. `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states (§9.7.1, §9.7.3) rather than by the hosts happening to agree, so parity tests check them against that form as well as against each other, on every PR. *Note: `Inference.complete` and every `DB` operation return an error in the browser — a deliberate platform boundary, since the credentials they need would be readable from page source; reach them through a server-side proxy via `Http`.* +Self-contained — no bundler. Serve with any HTTP server (`python -m http.server`). `IO.print` writes to the page; every other operation the browser target supports works identically to the CLI, apart from `md_parse`, whose two hand-written implementations still disagree on a few shapes the §9.7.3 subset does not pin (a tracked bug). `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states (§9.7.1, §9.7.3) rather than by the hosts happening to agree, so parity tests check them against that form as well as against each other, on every PR. *Note: `Inference.complete` and every `DB` operation return an error in the browser — a deliberate platform boundary, since the credentials they need would be readable from page source; reach them through a server-side proxy via `Http`.* ### WASI components diff --git a/spec/09-standard-library.md b/spec/09-standard-library.md index 7d1f92c4..00c55621 100644 --- a/spec/09-standard-library.md +++ b/spec/09-standard-library.md @@ -2269,12 +2269,14 @@ public fn md_render(@MdBlock -> @String) Renders an `MdBlock` to a canonical Markdown string. Always succeeds. The round-trip property `md_parse(md_render(b)) == Ok(b)` should hold: rendering then re-parsing preserves structure. Every runtime produces the same string (§12.9.3). -Two rules carry that property, and both follow from the ADT having no line-break node (see the design note above): +Four rules carry that property. The first two follow from the ADT having no line-break node (see the design note above); the last two from a container needing to be readable back as one block: - **A paragraph is one line.** Its inline content is rendered without internal newlines. A parser collapses a paragraph's soft line breaks to spaces on the way in, so nothing survives to be re-emitted. - **A container prefixes every line of every child.** A block quote writes `> ` before each rendered line and a bare `>` for a blank one; a list item writes its marker before the first line and an equal-width indent before the rest. Prefixing only a child's first line would leave a fenced block's body — or a nested block's continuation — outside its container, and the next `md_parse` would read it as a sibling. +- **A container separates its children.** A block quote writes a bare `>` between adjacent children, as a document writes a blank line between its own. Without it two quoted paragraphs render as two adjacent quoted lines, which re-parse as one paragraph. An empty container still occupies its line: a `MdBlockQuote` with no children renders `>`, because rendering it as nothing deletes the block and leaves the enclosing separator dangling. +- **A code span is fenced longer than its content.** The fence is one backtick more than the longest backtick run inside the span, with a single padding space on each side when the content itself starts or ends with a backtick. A fixed-width fence terminates on a run inside the content. (A span needing three or more backticks is not representable at the start of a line, where that run is a fenced-code-block opener; there is no escape syntax in this subset.) -Together these make `md_render` a fixed point: rendering its own output returns the same bytes. +Together these should make `md_render` a fixed point: re-parsing and re-rendering its output returns the same bytes. **Accessor functions for contracts:** diff --git a/spec/12-runtime.md b/spec/12-runtime.md index de91156e..0af289b6 100644 --- a/spec/12-runtime.md +++ b/spec/12-runtime.md @@ -658,9 +658,11 @@ The rows above fall into three kinds, and the distinction matters when reading a - **Deliberate boundaries.** `IO.read_file` / `IO.write_file` (no filesystem), `` (no accept loop), and `Inference.complete` / `DB.query` / `DB.execute` (the credential would be readable from page source and network traffic) return `Err` on every call **by definition of the browser target**, not pending a fix. Reach a filesystem, a database or a model provider through a server-side endpoint and call it with `Http`, which does run in the browser. - **Not yet implemented.** `IO.read_char` is the only row of this kind: the browser `Err` is a stub awaiting JSPI suspend/resume ([#609](https://github.com/aallan/vera/issues/609)), so unlike a boundary it is expected to become an `Ok` one day. -Across the surface the two runtimes actually share — State, contracts, JSON, Markdown and the rest of the non-IO operations — results are identical. The boundary rows are outside that claim by construction, having no browser counterpart to agree with, and an `Http` call's outcome is host-specific for a milder reason: the browser issues it through synchronous `XMLHttpRequest`, so a JavaScript host without one returns an explanatory `Err` where the reference runtime performs the request. +Across the surface the two runtimes actually share — State, contracts, JSON, `md_render` and the rest of the non-IO operations — results are identical. The boundary rows are outside that claim by construction, having no browser counterpart to agree with, and an `Http` call's outcome is host-specific for a milder reason: the browser issues it through synchronous `XMLHttpRequest`, so a JavaScript host without one returns an explanatory `Err` where the reference runtime performs the request. -Two operations reach that identity by carrying a canonical form the specification states rather than by both hosts happening to agree. `json_stringify` emits the one form §9.7.1 pins, down to separators and number rendering, and refuses a non-finite number on both hosts instead of one refusing and the other substituting `null`. `md_render` emits the canonical Markdown §9.7.3 describes, collapsing a paragraph's soft line breaks and re-applying each container's prefix to every line of every child, which is what makes the render a fixed point and the §9.7.3 round-trip property hold on both hosts. +Two operations reach that identity by carrying a canonical form the specification states rather than by both hosts happening to agree. `json_stringify` emits the one form §9.7.1 pins, down to separators and number rendering, and refuses a non-finite number on both hosts instead of one refusing and the other substituting `null`. `md_render` emits the canonical Markdown §9.7.3 describes — soft line breaks collapsed, each container's prefix re-applied to every line of every child, children separated, code spans fenced wider than their content — which is what makes the render a fixed point on both hosts. + +`md_parse` is the one operation on the shared surface where the requirement is not yet met. The two implementations are hand-written parsers for the §9.7.3 subset, and they still disagree on inputs the subset does not pin: how adjacent plain-text runs are grouped inside a paragraph (the largest class by count, and invisible to `md_render` because the runs concatenate to the same text), and a handful of block markers — a `+` bullet, an `n)` ordered marker, a list item separated from the next by a blank line, a list nested more than two deep, a thematic break written with internal spaces, and a table without a separator row. The divergence is a tracked bug, not part of the browser target's definition; the parity suite covers `md_parse` on every shape it does agree on, so the set cannot widen unnoticed. Fused async preserves the underlying value whenever the two hosts' results are comparable. Only the evaluation strategy differs, which is spec-conformant — §9.5.4 says an implementation MAY evaluate `async(e)` concurrently — and any difference that remains comes from the `Http` outcome underneath rather than from `async` itself. Mandatory parity tests enforce identical results across the shared surface. @@ -689,6 +691,6 @@ The `index.html` file uses an ES module script that imports from `vera-runtime.m The browser parity test suite (`tests/test_browser.py`) runs the examples the browser target can execute — and per-binding batteries over the Map, Set, Decimal, Json, Regex and Markdown host imports — through both the Python/wasmtime runtime and the Node.js/JS-runtime. The example corpus is two explicit lists in that file rather than the whole `examples/` directory, and the two carry different oracles: the examples exporting `main` are run and compared on stdout, while the ones reached as exported functions are called with fixed arguments and compared on the returned value. The per-binding batteries compare stdout. An example is excluded from the corpus when it reads stdin interactively, when it uses a host family the browser refuses (file IO, `DB`), or when it does not compile standalone, and each exclusion is recorded beside the list with its reason. This catches drift between the two implementations across the whole shared surface. The tests cover IO operations, State operations, contract violations, Markdown parsing/rendering, and browser bundle emission. -The two operations that carry a canonical form (§12.9.3) are tested three ways rather than by equality alone, because two hosts agreeing on a wrong answer would satisfy equality: each case asserts cross-host equality, the expected string written out, and — for `md_render` — stability under re-render, which is the observable form of §9.7.3's round-trip property. `json_stringify`'s number rendering is additionally checked differentially against a real `JSON.stringify` over a sample of doubles drawn from raw bit patterns, since the reference host renders numbers itself rather than delegating. Failures that are contracts rather than values are asserted two-sidedly: a non-finite `JNumber` must make the call fail on both hosts **and** produce no output, so a host that emitted `null` before failing could not read as a pass. The browser stubs are covered on two different shapes: `IO.read_file` and `IO.write_file` get per-host pinning, running both runtimes against a path that is really readable or writable so the native `Ok` and the browser `Err` are each asserted, while `IO.read_char` is checked in the browser alone — that the module links and the stub's `Err` arm is taken — with no native side to compare against. +The two operations that carry a canonical form (§12.9.3) are tested three ways rather than by equality alone, because two hosts agreeing on a wrong answer would satisfy equality: each case asserts cross-host equality, the expected string written out, and — for `md_render` — stability under re-render, which is the observable form of §9.7.3's round-trip property. Some rules are reachable only from an `MdBlock` a program *built* rather than parsed, so the Markdown battery renders constructed values too. `json_stringify`'s number rendering is additionally checked differentially against a real `JSON.stringify` over a sample of doubles drawn from raw bit patterns, since the reference host renders numbers itself rather than delegating. Failures that are contracts rather than values are asserted two-sidedly: a non-finite `JNumber` must make the call fail on both hosts **and** produce no output, so a host that emitted `null` before failing could not read as a pass. The browser stubs are covered on two different shapes: `IO.read_file` and `IO.write_file` get per-host pinning, running both runtimes against a path that is really readable or writable so the native `Ok` and the browser `Err` are each asserted, while `IO.read_char` is checked in the browser alone — that the module links and the stub's `Err` arm is taken — with no native side to compare against. Pre-commit hooks trigger parity tests on any change to the host binding surface (`vera/browser/`, `vera/codegen/api.py`, `vera/wasm/markdown.py`, `vera/markdown.py`). CI runs the full parity suite on every PR. diff --git a/tests/test_browser.py b/tests/test_browser.py index 92d7e95a..d0f57312 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -12,6 +12,8 @@ from __future__ import annotations +import contextlib +import io import json import math import random @@ -29,7 +31,7 @@ from vera.parser import parse_file from vera.resolver import ModuleResolver from vera.transform import transform -from vera.wasm.json_serde import format_json_number +from vera.wasm.json_serde import _non_finite_message, format_json_number # --------------------------------------------------------------------------- # Paths @@ -226,8 +228,16 @@ def _both_failures(src: str, tmp_path: Path, name: str) -> tuple[str, str]: src_path.write_text(src, encoding="utf-8") wasm_path, result = _compile_file(src_path, tmp_path) + # ``execute`` discards its stdout buffer when the exception is not a + # wasmtime trap, so the buffer cannot answer "did it print anything + # first?". ``tee_stdout`` mirrors every ``IO.print`` to + # ``sys.stdout`` as it happens, which survives the unwind — so + # redirecting it gives the native side the same observation the Node + # harness gives for free. + native_tee = io.StringIO() try: - native_out = _run_python(result) + with contextlib.redirect_stdout(native_tee): + native_out = execute(result, tee_stdout=True) except Exception as exc: # noqa: BLE001 — any failure is the signal native_msg = str(exc) else: @@ -235,6 +245,10 @@ def _both_failures(src: str, tmp_path: Path, name: str) -> tuple[str, str]: "native runtime did not fail; " f"stdout={native_out.stdout!r}" ) + assert native_tee.getvalue() == "", ( + "native runtime produced output on a failing call: " + f"{native_tee.getvalue()!r}" + ) node = _run_node(wasm_path) browser_msg = str(node.get("error") or "") @@ -3637,25 +3651,31 @@ class TestBrowserJsonStringifyNonFinite1293: """ @pytest.mark.parametrize( - ("case_id", "expr"), + ("case_id", "expr", "rendered"), [ - ("nan", "nan()"), - ("infinity", "infinity()"), - ("negative_infinity", "0.0 - infinity()"), + ("nan", "nan()", "NaN"), + ("infinity", "infinity()", "Infinity"), + ("negative_infinity", "0.0 - infinity()", "-Infinity"), ], ) def test_non_finite_fails_on_both_hosts( - self, case_id: str, expr: str, tmp_path: Path, + self, case_id: str, expr: str, rendered: str, tmp_path: Path, ) -> None: native, browser = _both_failures( self._SRC.format(expr=expr), tmp_path, f"jsonnf_{case_id}", ) - # One shared sentence, so a caller reading either host's failure - # learns the same thing. Substring, not equality: wasmtime and - # the Node harness each wrap the host message in their own frame. - needle = "not representable in JSON" - assert needle in native, native - assert needle in browser, browser + # The WHOLE sentence, taken from the reference implementation, so + # the browser's hand-copied duplicate in ``runtime.mjs`` is held + # against the original rather than against a shared fragment + # short enough for both to satisfy while saying different things + # — including the value's own spelling ("NaN" / "Infinity" / + # "-Infinity"), which each host derives independently. + expected = _non_finite_message(rendered) + assert "not representable in JSON" in expected # guards the guard + # Substring, not equality: wasmtime and the Node harness each + # wrap the host message in a frame of their own. + assert expected in native, native + assert expected in browser, browser class TestCanonicalNumberFormatMatchesEcmascript1293: @@ -4010,9 +4030,15 @@ def _md_round_trip_src(markdown: str, *, times: int = 1) -> str: ( "nested_bq_fence", r"> ## Quoted\n>\n> ```py\n> x = 1\n> ```\n", - "> ## Quoted\n> ```py\n> x = 1\n> ```", + # The bare `>` between the quote's two children survives the + # round trip since the #1294 review; the render is now the + # input back, byte for byte, minus the trailing newline. + "> ## Quoted\n>\n> ```py\n> x = 1\n> ```", ), ("plain_paragraph", r"hello\nworld\n", "hello world"), + # A quote holding two paragraphs — the shape whose separator the + # reference renderer dropped, merging them into one on re-parse. + ("blockquote_two_paragraphs", r"> a\n>\n> b\n", "> a\n>\n> b"), ] # Corpus for the §9.7.3 round-trip property. The first eight mirror @@ -4036,6 +4062,22 @@ def _md_round_trip_src(markdown: str, *, times: int = 1) -> str: ("blockquote_pair", r"> a\n> b\n"), ("nested_bq_fence", r"> ## Quoted\n>\n> ```py\n> x = 1\n> ```\n"), ("plain_paragraph", r"hello\nworld\n"), + # Multi-child containers — the shape whose separator the reference + # renderer dropped (#1294 review). + ("blockquote_two_paragraphs", r"> a\n>\n> b\n"), + ("blockquote_para_then_list", r"> a\n>\n> - b\n> - c\n"), + ("blockquote_three_children", + r"> # H\n>\n> para\n>\n> ```py\n> x = 1\n> ```\n"), + # Lazy continuation: an unmarked line continues the quote. + ("blockquote_lazy_continuation", r"> a\nb\n"), + ("blockquote_no_space", r">no space\n"), + # An empty container: it has to survive its own render or the block + # disappears and the document's spacing goes with it. + ("blockquote_empty", r"---\n>\n"), + ("blockquote_empty_between", r"> a\n\n>\n\n> b\n"), + # A code span whose content holds a backtick — the shape that tells + # a run-length scan apart from a next-single-backtick scan. + ("code_span_interior_tick", r"``a`b``"), ("quoted_list", r"> - a\n> b\n> - c\n"), ("quoted_multiline_fence", r"> ```sh\n> one\n> two\n> ```\n"), ("list_with_fence", r"- item\n ```py\n a = 1\n b = 2\n ```\n"), @@ -4147,24 +4189,92 @@ class TestBrowserMarkdownRenderConstructedAdt1294: is still reachable, and still has to agree across the two hosts. """ - def test_code_span_containing_a_backtick(self, tmp_path: Path) -> None: - """A code span holding a backtick needs the longer fence. + @pytest.mark.parametrize(("case_id", "code", "rendered"), [ + ("plain", "code", "`code`"), + ("one_backtick", "a`b", "``a`b``"), + ("two_backticks", "a``b", "```a``b```"), + ("leading_backtick", "`x", "`` `x ``"), + ("trailing_backtick", "x`", "`` x` ``"), + ]) + def test_code_span_fence( + self, case_id: str, code: str, rendered: str, tmp_path: Path, + ) -> None: + """The fence is one backtick longer than the content's longest + run, padded only when the content starts or ends with one. + + Reachable only from a constructed ADT for the multi-backtick + cases, which is why the browser renderer was missing the rule + entirely while every parse-driven test passed — and why the + reference's own rule ("two backticks and padding") was wrong for + two backticks without anything noticing. + """ + src = f""" +public fn main(@Unit -> @Unit) + requires(true) ensures(true) effects() +{{ + IO.print(md_render(MdDocument([MdParagraph([MdCode("{code}")])]))) +}} +""" + assert _parity_stdout(src, tmp_path, f"md_tick_{case_id}") == rendered + + @pytest.mark.parametrize(("case_id", "source", "expected"), [ + # Content WITH an interior backtick, so a scan that stops at the + # next single tick lands somewhere else. ``` ``double`` ``` on + # its own does not distinguish the two scans: both recover + # "double", which is how a wrong parser passes a plausible test. + ("interior_tick", r"``a`b``", "``a`b``"), + ("plain_double", r"``double``", "`double`"), + # A three-backtick run at the start of a line is a BLOCK fence + # before any inline parsing happens, so this is an unterminated + # code block whose language tag is the rest of the line. Both + # hosts agree on that, which is the claim here; that the shape + # is unrepresentable at line start is a limitation of the + # §9.7.3 subset, pinned natively in tests/test_markdown.py. + ("triple_is_a_block_fence", r"```a``b```", "```a``b```\n\n```"), + ]) + def test_code_span_parses_by_run_length( + self, case_id: str, source: str, expected: str, tmp_path: Path, + ) -> None: + """``md_parse`` closes a span on a run of EQUAL length. - Neither parser produces one — both stop a span at the first - closing backtick — so this rule is reachable only from a - constructed ADT, which is exactly why the browser renderer was - missing it while every parse-driven test passed. Wrapping it in - a single backtick pair would re-parse as an empty span followed - by stray text, so the round trip is checked too. + The browser scanned for the next single backtick, so + ``` ``a`b`` ``` closed on the interior tick and left the rest as + stray text — a parse divergence, not a render one, and the + reason the fence rule above needs the parser fixed in the same + change: without it the browser cannot read back its own output. + """ + src = f""" +public fn main(@Unit -> @Unit) + requires(true) ensures(true) effects() +{{ + match md_parse("{source}") {{ + Ok(@MdBlock) -> IO.print(md_render(@MdBlock.0)), + Err(@String) -> IO.print(string_concat("ERR:", @String.0)) + }} +}} +""" + assert _parity_stdout(src, tmp_path, f"md_span_{case_id}") == expected + + def test_empty_blockquote_still_occupies_a_line( + self, tmp_path: Path, + ) -> None: + """A quote with no children renders as a bare ``>``. + + Rendering it as no lines at all makes the block vanish on + re-parse and turns the document's separator into a stray blank + line, so ``---\\n>`` came back as just ``---``. Built directly + *and* exercised through the corpus round trip below, because the + constructed form is what pins the bytes and the parsed form is + what proves the parser agrees they are the same block. """ src = """ public fn main(@Unit -> @Unit) requires(true) ensures(true) effects() { - IO.print(md_render(MdDocument([MdParagraph([MdCode("a`b")])]))) + IO.print(md_render(MdDocument([MdThematicBreak(), MdBlockQuote([])]))) } """ - assert _parity_stdout(src, tmp_path, "md_code_tick") == "`` a`b ``" + assert _parity_stdout(src, tmp_path, "md_empty_bq") == "---\n\n>" def test_multi_line_code_block_inside_a_blockquote( self, tmp_path: Path, @@ -4188,14 +4298,19 @@ def test_multi_line_code_block_inside_a_blockquote( "> ```sh\n> one\n> two\n> three\n> ```" ) - def test_blank_line_between_blocks_inside_a_blockquote( + def test_nested_documents_separator_is_quoted( self, tmp_path: Path, ) -> None: - """The separator between a document's blocks is itself quoted. - - A blockquote wrapping two paragraphs renders the gap as a bare - ``>``, not as an empty line — an empty line would end the quote - on re-parse and split one blockquote into two. + """A *nested ``MdDocument``* separates its blocks with a blank + line, and the enclosing quote turns that into a bare ``>``. + + The separator here comes from the ``MdDocument`` arm, not the + blockquote arm — the quote has exactly one child. What this + pins is the quoting of a child's blank line: an unquoted empty + line would end the quote on re-parse and split one blockquote + into two. The blockquote arm's own separator is a different + rule with a different owner, pinned by the test below on the + shape ``md_parse`` actually produces. """ src = """ public fn main(@Unit -> @Unit) @@ -4212,3 +4327,30 @@ def test_blank_line_between_blocks_inside_a_blockquote( assert _parity_stdout(src, tmp_path, "md_bq_blank_adt") == ( "> first\n>\n> second" ) + + def test_blockquote_separates_its_own_children( + self, tmp_path: Path, + ) -> None: + """The twin of the test above, on the shape the parser builds. + + ``md_parse`` wraps a quote's blocks as ``MdBlockQuote``'s direct + children — there is no nested ``MdDocument`` — so the separator + has to come from the blockquote arm itself. It did not: two + quoted paragraphs rendered as two adjacent quoted lines, which + ``md_parse`` reads back as ONE paragraph, silently and on both + hosts (#1294 review). The test above passed throughout, + because the arm it exercises was never the broken one. + """ + src = """ +public fn main(@Unit -> @Unit) + requires(true) ensures(true) effects() +{ + IO.print(md_render(MdDocument([MdBlockQuote([ + MdParagraph([MdText("first")]), + MdParagraph([MdText("second")]) + ])]))) +} +""" + assert _parity_stdout(src, tmp_path, "md_bq_children_adt") == ( + "> first\n>\n> second" + ) diff --git a/tests/test_markdown.py b/tests/test_markdown.py index 6b70189d..4f8e2824 100644 --- a/tests/test_markdown.py +++ b/tests/test_markdown.py @@ -312,6 +312,14 @@ class TestRoundTrip: "1. first\n2. second", "> quoted", "| A | B |\n| --- | --- |\n| 1 | 2 |", + # Multi-child containers. Every entry above is single-line or + # fence-only, so none of them can see a container that drops the + # separator between its children — which the blockquote arm did + # until v0.1.12 (#1294 review), turning two quoted paragraphs + # into one. + "> a\n>\n> b", + "> a\n>\n> - b\n> - c", + "> # H\n>\n> para\n>\n> ```py\n> x = 1\n> ```", ]) def test_round_trip(self, markdown: str) -> None: doc = parse_markdown(markdown) @@ -319,6 +327,101 @@ def test_round_trip(self, markdown: str) -> None: doc2 = parse_markdown(rendered) assert doc == doc2 + @pytest.mark.parametrize("markdown", [ + "> a\n>\n> b", + "> a\n>\n> - b\n> - c", + "> # H\n>\n> para\n>\n> ```py\n> x = 1\n> ```", + "- item 1\n- item 2", + "> quoted", + # An empty quote is a block like any other: it has to survive + # its own render, or a `>` in a document disappears on the + # round trip and takes the document's block spacing with it. + ">", + "---\n>", + "> a\n\n>\n\n> b", + ]) + def test_render_is_a_fixed_point(self, markdown: str) -> None: + """Rendering the render changes nothing. + + The ADT equality above is the property spec §9.7.3 states; this + is the weaker string form the cross-host battery in + ``tests/test_browser.py`` can observe. Both are asserted here + so a future divergence between them is visible: a renderer that + satisfies the string form while losing structure would pass one + and fail the other. + """ + once = render_markdown(parse_markdown(markdown)) + twice = render_markdown(parse_markdown(once)) + assert twice == once + + @pytest.mark.parametrize(("code", "rendered"), [ + ("code", "`code`"), + ("a`b", "``a`b``"), + ("a``b", "```a``b```"), + ("`x", "`` `x ``"), + ("x`", "`` x` ``"), + (" ", "` `"), + ]) + def test_code_span_fence_round_trips( + self, code: str, rendered: str, + ) -> None: + """A code span is fenced with one more backtick than its longest + internal run, padded only when it starts or ends with one. + + The old rule was "two backticks and padding spaces if the + content holds any backtick", which is right for one backtick and + wrong for two: ``MdCode("a``b")`` rendered ``` `` a``b `` ```, + whose closing run is the one *inside* the content, so it read + back as a different document. + + The round trip is asserted with the span preceded by text. A + three-backtick fence at the *start of a line* is a block fence + to the block parser, which is a separate collision — pinned on + its own below rather than folded in here, so this case tests the + inline rule and nothing else. + """ + doc = MdParagraph((MdCode(code),)) + assert render_markdown(doc) == rendered + + embedded = MdParagraph((MdText("x "), MdCode(code))) + line = render_markdown(embedded) + assert line == "x " + rendered + assert parse_markdown(line).children[0] == embedded + + def test_code_span_needing_a_triple_fence_collides_at_line_start( + self, + ) -> None: + """A span whose content holds ``` `` ``` needs a three-backtick + fence, and at the start of a line that IS a block fence. + + Measured, not assumed: the block parser matches + ``^(`{3,}|~{3,})`` before any inline parsing happens, so the + paragraph is read as an empty fenced code block whose language + tag is the rest of the line. There is no escape syntax in the + §9.7.3 subset to write it another way, so the shape is simply + not representable at line start — the same on both runtimes, + since neither has anything to disagree about. Pinned so the + limitation is visible rather than inferred from a gap. + """ + doc = MdParagraph((MdCode("a``b"),)) + line = render_markdown(doc) + assert line == "```a``b```" + assert parse_markdown(line).children[0] == MdCodeBlock("a``b```", "") + + def test_blockquote_children_are_separated(self) -> None: + """A blockquote separates its children with a bare ``>``. + + Directly pinned, not inferred from the round trip, because the + round trip is satisfiable in two ways and only one is right: a + renderer could also "preserve structure" by merging the two + paragraphs at parse time. The bytes say which happened. + """ + doc = MdBlockQuote(( + MdParagraph((MdText("first"),)), + MdParagraph((MdText("second"),)), + )) + assert render_markdown(doc) == "> first\n>\n> second" + # ===================================================================== # Query function tests diff --git a/vera/README.md b/vera/README.md index d471d0ce..68d1665e 100644 --- a/vera/README.md +++ b/vera/README.md @@ -599,7 +599,7 @@ The WASM import interface is the portability contract: the compiled `.wasm` bina **GC reachability discipline (JS host side):** JS host functions that allocate multiple WASM heap blocks and hold intermediates in JS locals must root those intermediates on the shadow stack — otherwise EAGER_GC (and, under pressure, normal GC) reclaims them mid-walk. The runtime exports two helpers: `gcShadowPush(ptr)` writes a pointer to `$gc_sp` and advances it (throws if `$gc_sp` / `$gc_stack_limit` aren't exported, since that means the module was built without GC support but is calling allocators that can trigger GC), and `gcGuard(fn)` saves `$gc_sp` at entry and restores it on exit (success or exception). This is the browser parallel of the CLI-side `_ShadowGuard` context manager added in v0.0.158 (#692). The walkers `writeJson` / `writeHtml` and the parsers `json_parse` / `html_parse` wrap their bodies in `gcGuard` and push intermediates (`arrPtr`, `wrapperPtr`, `jsonPtr`) as soon as each is allocated — see `runtime.mjs` for the canonical pattern. Without this, `Map` / `Set` and similar heap-pointer-keyed collections drop values under GC pressure (#708). -**Parity enforcement:** `tests/test_browser.py` runs the examples the browser target can execute — two explicit lists in that file, not the whole `examples/` directory, since an example that reads stdin interactively, uses a refused host family (file IO, `DB`), or does not compile standalone cannot be compared — plus per-binding batteries over the Map/Set/Decimal/Json/Regex/Markdown host imports, through both Python/wasmtime and Node.js/JS-runtime. The two example lists carry different oracles: the examples exporting `main` are run and compared on stdout, while the ones reached as exported functions are called with fixed arguments and compared on the returned value. The per-binding batteries compare stdout. `json_stringify` and `md_render` are compared the same way and additionally against the canonical form the specification states for each (§9.7.1, §9.7.3), because cross-host equality alone would be satisfied by two hosts agreeing on a wrong answer; `md_render` is also asserted stable under re-render, and `json_stringify`'s number rendering is checked differentially against a real `JSON.stringify`. The browser stubs are covered on two different shapes: `IO.read_file` and `IO.write_file` get the same per-host pinning, run through both runtimes against a path that really is readable or writable so the native `Ok` and the browser `Err` are each asserted (a missing file or an unwritable directory would `Err` on both sides and prove nothing), while `IO.read_char` is exercised in Node alone — the module links and the stub's `Err` arm returns `0` — with no native run to compare against. `Inference` and `DB` return `Err` from every browser operation, which is a deliberate platform boundary — the credentials they need would be readable from page source — rather than a divergence awaiting a fix. Pre-commit hooks and CI trigger these tests on any change to the host binding surface. +**Parity enforcement:** `tests/test_browser.py` runs the examples the browser target can execute — two explicit lists in that file, not the whole `examples/` directory, since an example that reads stdin interactively, uses a refused host family (file IO, `DB`), or does not compile standalone cannot be compared — plus per-binding batteries over the Map/Set/Decimal/Json/Regex/Markdown host imports, through both Python/wasmtime and Node.js/JS-runtime. The two example lists carry different oracles: the examples exporting `main` are run and compared on stdout, while the ones reached as exported functions are called with fixed arguments and compared on the returned value. The per-binding batteries compare stdout. `json_stringify` and `md_render` are compared the same way and additionally against the canonical form the specification states for each (§9.7.1, §9.7.3), because cross-host equality alone would be satisfied by two hosts agreeing on a wrong answer; `md_render` is also asserted stable under re-render and exercised on `MdBlock` values the test *builds*, since several renderer rules are unreachable through `md_parse`, and `json_stringify`'s number rendering is checked differentially against a real `JSON.stringify`. `md_parse` itself is the one shared operation still divergent between the hosts — plain-text run grouping inside a paragraph, and a handful of block markers §9.7.3 does not pin — tracked as a bug in `KNOWN_ISSUES.md`; the suite pins the shapes the two do agree on. The browser stubs are covered on two different shapes: `IO.read_file` and `IO.write_file` get the same per-host pinning, run through both runtimes against a path that really is readable or writable so the native `Ok` and the browser `Err` are each asserted (a missing file or an unwritable directory would `Err` on both sides and prove nothing), while `IO.read_char` is exercised in Node alone — the module links and the stub's `Err` arm returns `0` — with no native run to compare against. `Inference` and `DB` return `Err` from every browser operation, which is a deliberate platform boundary — the credentials they need would be readable from page source — rather than a divergence awaiting a fix. Pre-commit hooks and CI trigger these tests on any change to the host binding surface. `browser/emit.py` provides `emit_browser_bundle()` for the `vera compile --target browser` CLI command, which produces a ready-to-serve directory (module.wasm + vera-runtime.mjs + index.html). @@ -752,7 +752,7 @@ The `ERROR_CODES` dict in `errors.py` maps every code to a short description (15 ## Test Suite -Testing spans a **pytest suite** of 10,676 tests across 164 files — compiler-internals unit tests plus a **conformance suite** (216 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. +Testing spans a **pytest suite** of 10,714 tests across 164 files — compiler-internals unit tests plus a **conformance suite** (216 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. See **[TESTING.md](../TESTING.md)** for the comprehensive testing reference -- test file table, conformance suite details, compiler code coverage, language feature coverage, helper conventions, validation scripts, CI pipeline, and guidelines for adding tests. diff --git a/vera/browser/runtime.mjs b/vera/browser/runtime.mjs index 2919172d..89609319 100644 --- a/vera/browser/runtime.mjs +++ b/vera/browser/runtime.mjs @@ -509,12 +509,27 @@ function parseInlines(text) { const n = text.length; while (i < n) { - // Code span + // Code span: a run of N backticks closes on the next run of N, + // mirroring _parse_inlines in vera/markdown.py. This scanned for + // the next *single* backtick, so ``x`` opened an empty span at the + // first tick and dropped the content out of the span entirely. + // With no closing run the scan falls through to the plain-text + // accumulator below, as it did before. if (text[i] === '`') { - let end = text.indexOf('`', i + 1); - if (end !== -1) { - result.push(new MdCode(text.slice(i + 1, end))); - i = end + 1; + let runEnd = i; + while (runEnd < n && text[runEnd] === '`') runEnd++; + const runLen = runEnd - i; + const closeIdx = text.indexOf('`'.repeat(runLen), runEnd); + if (closeIdx !== -1) { + let content = text.slice(runEnd, closeIdx); + // Undo the renderer's padding: exactly one leading and one + // trailing space, and only when both are present. + if (content.length >= 2 && content.startsWith(' ') + && content.endsWith(' ')) { + content = content.slice(1, -1); + } + result.push(new MdCode(content)); + i = closeIdx + runLen; continue; } } @@ -588,6 +603,21 @@ function parseInlines(text) { // -- Block parser -- +/** + * Does this line open a block-level construct? Mirrors + * `_is_block_start` in vera/markdown.py, regex for regex. It is the + * predicate that bounds a blockquote's lazy continuation: an unmarked + * line belongs to the open quote unless it starts a block of its own. + */ +function isBlockStart(line) { + return /^#{1,6}\s/.test(line) // ATX heading + || /^(`{3,}|~{3,})/.test(line) // fence + || /^(?:---+|\*{3,}|_{3,})\s*$/.test(line) // thematic break + || /^>/.test(line) // block quote + || /^[-*+]\s/.test(line) // unordered item + || /^\d+[.)]\s/.test(line); // ordered item +} + function parseBlocks(text) { const lines = text.split('\n'); const blocks = []; @@ -639,11 +669,26 @@ function parseBlocks(text) { continue; } - // Block quote: > ... - if (line.startsWith('> ') || line === '>') { + // Block quote: '>' optionally followed by ONE whitespace character, + // mirroring _BLOCKQUOTE_LINE = ^>\s? in vera/markdown.py. The old + // predicate demanded the space, so `>no space` fell through to the + // paragraph branch and parsed as literal text where the reference + // read a quote. + if (/^>/.test(line)) { const quoteLines = []; - while (i < lines.length && (lines[i].startsWith('> ') || lines[i] === '>')) { - quoteLines.push(lines[i].startsWith('> ') ? lines[i].slice(2) : ''); + while (i < lines.length) { + const marked = lines[i].match(/^>\s?(.*)$/); + if (marked) { + quoteLines.push(marked[1]); + } else if (lines[i].trim() !== '' && !isBlockStart(lines[i])) { + // Lazy continuation (markdown.py's `elif` in the same loop): + // an unmarked, non-blank line that opens no block of its own + // continues the quote's paragraph. Without this branch the + // line escaped the quote entirely. + quoteLines.push(lines[i]); + } else { + break; + } i++; } const inner = parseBlocks(quoteLines.join('\n')); @@ -706,7 +751,9 @@ function parseBlocks(text) { while (i < lines.length && lines[i].trim() !== '' && !lines[i].match(/^#{1,6}\s/) && !lines[i].match(/^(`{3,}|~{3,})/) && - !lines[i].startsWith('> ') && + // '^>' , not "starts with '> '": a paragraph ends at any + // quote marker, spaced or not (mirrors _BLOCKQUOTE_LINE). + !/^>/.test(lines[i]) && !/^[-*]\s/.test(lines[i]) && !/^\d+\.\s/.test(lines[i]) && !/^(\*{3,}|-{3,}|_{3,})\s*$/.test(lines[i])) { @@ -743,12 +790,22 @@ function parseMarkdown(text) { function renderInline(node) { switch (node.tag) { case 'MdText': return node.text; - case 'MdCode': - // A span containing a backtick needs a longer fence and padding - // spaces, or it re-parses as a shorter span plus stray text. - return node.text.includes('`') - ? '`` ' + node.text + ' ``' - : '`' + node.text + '`'; + case 'MdCode': { + // One backtick longer than the content's longest run, padded only + // when the content starts or ends with one — mirrors + // _render_code_span. A fixed two-backtick fence terminates on + // the content's own `` and loses the rest. + let longest = 0; + let run = 0; + for (const ch of node.text) { + run = ch === '`' ? run + 1 : 0; + if (run > longest) longest = run; + } + const fence = '`'.repeat(longest + 1); + const pad = (node.text.startsWith('`') || node.text.endsWith('`')) + ? ' ' : ''; + return fence + pad + node.text + pad + fence; + } case 'MdEmph': return '*' + node.children.map(renderInline).join('') + '*'; case 'MdStrong': return '**' + node.children.map(renderInline).join('') + '**'; case 'MdLink': return '[' + node.children.map(renderInline).join('') + '](' + node.url + ')'; @@ -779,12 +836,18 @@ function renderBlockLines(node) { case 'MdCodeBlock': return ['```' + node.lang, ...node.code.split('\n'), '```']; case 'MdBlockQuote': { + // An empty quote still occupies a line; rendering it as nothing + // makes the block vanish on re-parse (mirrors _render_block). + if (node.children.length === 0) return ['>']; const out = []; - for (const child of node.children) { + node.children.forEach((child, i) => { + // A bare '>' between children, mirroring _render_block: without + // it a quote holding two paragraphs re-parses as one. + if (i > 0) out.push('>'); for (const line of renderBlockLines(child)) { out.push(line ? '> ' + line : '>'); } - } + }); return out; } case 'MdList': { @@ -2689,10 +2752,21 @@ function buildImportObject(module, moduleBytes) { } return v; }); - // JSON.stringify can return undefined for unsupported values - // (e.g. bare undefined, symbols, functions). Fall back to "null" - // to match the JSON spec and avoid allocString crashing. - return allocString(text !== undefined ? text : "null"); + // JSON.stringify returns undefined for values with no JSON form + // (bare undefined, symbols, functions). readJson cannot produce + // one, so this is unreachable — but it used to fall back to + // "null", which is the same silent substitution #1293 removed one + // layer up, and an unreachable branch is exactly where a silent + // wrong answer survives unnoticed. Raise instead, matching + // dumps_canonical's TypeError for a value outside read_json's + // domain: whatever produced it, the ADT walk is wrong. + if (text === undefined) { + throw new Error( + "json_stringify: readJson produced a value with no JSON " + + "representation; the ADT walk is wrong" + ); + } + return allocString(text); }; } diff --git a/vera/markdown.py b/vera/markdown.py index ad2f41bf..40f5ac4d 100644 --- a/vera/markdown.py +++ b/vera/markdown.py @@ -518,10 +518,25 @@ def _render_block(block: MdBlock) -> list[str]: return lines if isinstance(block, MdBlockQuote): + if not block.children: + # A quote with nothing in it still occupies a line. Render + # it as no lines at all and the block vanishes on re-parse, + # leaving the document's separator as a stray blank line — + # `---\n>` renders `---\n` and comes back as just `---`. + return [">"] result = [] - for child in block.children: - child_lines = _render_block(child) - for line in child_lines: + for i, child in enumerate(block.children): + # #1294 review: a bare ``>`` between children, exactly as + # MdDocument puts a blank line between its own. Without it + # a quote holding two paragraphs renders as two adjacent + # quoted lines, which re-parses as ONE paragraph — the + # structure is gone and no later pass can tell. The + # separator is unconditional rather than emitted only where + # the next block would otherwise merge: one construct, one + # textual representation (§0.2.3). + if i > 0: + result.append(">") + for line in _render_block(child): result.append(f"> {line}" if line else ">") return result @@ -563,6 +578,27 @@ def _render_block(block: MdBlock) -> list[str]: return [] +def _render_code_span(code: str) -> str: + """Fence a code span so it reads back as itself. + + The fence is one backtick longer than the longest run *inside* the + content, because `_parse_inlines` closes a span on the first run of + equal length — a fixed two-backtick fence therefore terminates on + the content's own ``` `` ``` and loses the rest. Padding spaces are + added only when the content starts or ends with a backtick, where + they are what keeps the fence and the content from merging into one + longer run; the parser strips exactly one such pair. + """ + longest = 0 + run = 0 + for ch in code: + run = run + 1 if ch == "`" else 0 + longest = max(longest, run) + fence = "`" * (longest + 1) + pad = " " if code.startswith("`") or code.endswith("`") else "" + return f"{fence}{pad}{code}{pad}{fence}" + + def _render_inlines(inlines: tuple[MdInline, ...]) -> str: """Render inline content to a string.""" parts: list[str] = [] @@ -570,11 +606,7 @@ def _render_inlines(inlines: tuple[MdInline, ...]) -> str: if isinstance(inline, MdText): parts.append(inline.text) elif isinstance(inline, MdCode): - # Use backtick wrapping that avoids conflicts - if "`" in inline.code: - parts.append(f"`` {inline.code} ``") - else: - parts.append(f"`{inline.code}`") + parts.append(_render_code_span(inline.code)) elif isinstance(inline, MdEmph): parts.append(f"*{_render_inlines(inline.children)}*") elif isinstance(inline, MdStrong): From 300a7c27536e72026203823abf6d6540409deef9 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 01:05:39 +0100 Subject: [PATCH 08/69] Point the surviving md_parse and traceback findings at their trackers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review round's two findings are filed, so KNOWN_ISSUES.md carries a row for each and the prose that deferred to "a tracked bug" names it. * #1301 — md_parse diverges between the hosts on nine measured input classes. The row already existed with a placeholder in the Issue column; it now links the issue. * #1302 — a non-finite JNumber reaching json_stringify surfaces as a 61-line Python traceback rather than the 5-line formatted error a contract violation gets, because execute() classifies on the exception's type NAME and a ValueError from a host callback is not Trap or WasmtimeError. New row. Both rows sit at the head of the Bugs table, keeping its descending order (1302, 1301, 1296, …). The inline citation restores what the same sentences carried before this branch removed #1293 and #1294 from them: spec §12.9.3, README, FAQ, TESTING, vera/README and the landing page all name #1301 instead of pointing at KNOWN_ISSUES generically. Note for the tracker, not fixed here: #1298 and #1299 are open and bug-labelled and have no Bugs-table row. Both were filed after this branch last touched the file and neither is this branch's work, so writing rows for them would mean describing findings that were not measured here. Skip-changelog: issue references and a KNOWN_ISSUES row; the behaviour they describe is already in the [Unreleased] bullets. Co-Authored-By: Claude --- FAQ.md | 2 +- KNOWN_ISSUES.md | 3 ++- README.md | 2 +- TESTING.md | 2 +- docs/index.html | 2 +- docs/index.md | 2 +- docs/llms-full.txt | 2 +- scripts/build_site.py | 2 +- spec/12-runtime.md | 2 +- vera/README.md | 2 +- 10 files changed, 11 insertions(+), 10 deletions(-) diff --git a/FAQ.md b/FAQ.md index 78f0740c..a46d7c2c 100644 --- a/FAQ.md +++ b/FAQ.md @@ -170,7 +170,7 @@ vera compile --target browser examples/hello_world.vera # index.html ``` -Serve it with any HTTP server and open `index.html` — no build step, no bundler, no dependencies. The JavaScript runtime provides browser-appropriate implementations of all Vera host bindings: `IO.print` writes to the page, `IO.read_line` uses `prompt()`, and all other operations (State, contracts, JSON, Markdown rendering) work identically to the wasmtime runtime. `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states — §9.7.1 and §9.7.3 — rather than by the two hosts happening to agree, which is what the parity suite checks them against. `md_parse` is the exception: the two hand-written parsers still disagree on how a paragraph's plain-text runs are grouped — invisible to `md_render`, since they concatenate to the same text — and on a few block markers the §9.7.3 subset leaves open, such as a `+` bullet or a list nested more than two deep. That is a tracked bug, listed in [KNOWN_ISSUES.md](KNOWN_ISSUES.md). +Serve it with any HTTP server and open `index.html` — no build step, no bundler, no dependencies. The JavaScript runtime provides browser-appropriate implementations of all Vera host bindings: `IO.print` writes to the page, `IO.read_line` uses `prompt()`, and all other operations (State, contracts, JSON, Markdown rendering) work identically to the wasmtime runtime. `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states — §9.7.1 and §9.7.3 — rather than by the two hosts happening to agree, which is what the parity suite checks them against. `md_parse` is the exception: the two hand-written parsers still disagree on how a paragraph's plain-text runs are grouped — invisible to `md_render`, since they concatenate to the same text — and on a few block markers the §9.7.3 subset leaves open, such as a `+` bullet or a list nested more than two deep. That is a tracked bug ([#1301](https://github.com/aallan/vera/issues/1301)). Two effects are refused outright rather than merely differing. `Inference` and `DB` return an explanatory `Err` from every operation in the browser, because the API key or database credential they would need is readable from page source and network traffic in client-side JavaScript. Reach them through a server-side endpoint and call it with `Http`, which does run in the browser — it is backed by `XMLHttpRequest`, not a stub. That refusal is a deliberate platform boundary; spec §9.5.5 states it for `Inference`. diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index c92ce342..b92bc6a6 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -8,7 +8,8 @@ Defects in shipped compiler, runtime, or tooling behaviour — this table matche | Bug | Issue | |-----|-------| -| `md_parse` diverges between the native and browser runtimes on **211 of 1,471** adversarial inputs (14.3%) and **329 of 4,850** blank-line-separated sections of the project's own documentation (6.8%), measured by comparing the two ADTs directly rather than their renders. Nine classes, each with a one-line repro. The largest by a wide margin — 173 of the 211 — is *plain-text run grouping*: the browser emits one `MdText` per scan segment where the reference coalesces adjacent runs, so `**unclosed` is `[MdEmph([]), MdText("unclosed")]` natively and `[MdText("*"), MdText("*unclosed")]` in the browser. That class is invisible to `md_render` (the runs concatenate to the same text) but not to a Vera program that matches on the ADT, which is what makes it a §12.9.3 violation rather than a cosmetic one. The rest are render-visible: emphasis/strong scanning (`***both***` renders `**both****` natively, `***both***` in the browser); list-continuation indent width, where the reference strips exactly two (or three) characters and the browser strips all leading whitespace (`- a\n b` → `- a b` vs `- a b`); a `+` bullet, unrecognised in the browser; an `n)` ordered marker, likewise; a loose list, one list natively and two in the browser (`- a\n\n- b`); nesting past two levels, flattened in the browser (`- a\n - b\n - c`); a thematic break with internal spaces (`* * *`); and a table without a separator row (`\| a \| b \|\n- li`). Neither implementation is the specification — §9.7.3 pins the ADT, not the grammar that produces it — so closing this means choosing a parse for each class and stating it, then mirroring. Successor to [#1294](https://github.com/aallan/vera/issues/1294), which closed the `md_render` half; the parity suite pins every shape the two implementations do agree on, so the set cannot widen unnoticed. | `#TBD-SUCCESSOR` | +| A non-finite `JNumber` reaching `json_stringify` fails with the right sentence and the wrong presentation: `vera run` emits a **61-line, 3,214-character Python traceback** — 15 stack frames, 7 of them inside `.venv/`, and **no mention of the user's `.vera` file at all** — with the actual message (`json_stringify: NaN is not representable in JSON — RFC 8259 has no NaN or Infinity. …`) as its last line. A contract violation in the same program shape produces **5 lines**: the failed clause and a source backtrace with `.vera` line numbers. The buffered stdout is discarded on the way out rather than surfaced, so the `#522` output-preservation contract is lost too. The invariant this breaks is already written down, in a comment on `host_print` in `vera/codegen/api.py`: "A user-level program must never produce a Python traceback regardless of what it does — the `WasmTrapError` contract from #516/#522/#547 holds here too." Root cause: `execute()` converts an exception into `WasmTrapError` only when `type(exc).__name__` is `Trap` or `WasmtimeError`, and a `ValueError` raised inside a host callback arrives at that handler as a `ValueError`, so the branch is skipped — no classification, no source-map frame resolution, no captured output. `json_stringify` is the case that surfaced it ([#1293](https://github.com/aallan/vera/issues/1293)), but the gap is generic: any host callback raising a non-`Trap` Python exception takes the same path. Fix direction: give host-callback failures a Vera-native shape — a dedicated error type classified alongside the traps, or widen the conversion to any exception escaping a host import — carrying the captured stdout and a source-mapped backtrace either way. | [#1302](https://github.com/aallan/vera/issues/1302) | +| `md_parse` diverges between the native and browser runtimes on **211 of 1,471** adversarial inputs (14.3%) and **329 of 4,850** blank-line-separated sections of the project's own documentation (6.8%), measured by comparing the two ADTs directly rather than their renders. Nine classes, each with a one-line repro. The largest by a wide margin — 173 of the 211 — is *plain-text run grouping*: the browser emits one `MdText` per scan segment where the reference coalesces adjacent runs, so `**unclosed` is `[MdEmph([]), MdText("unclosed")]` natively and `[MdText("*"), MdText("*unclosed")]` in the browser. That class is invisible to `md_render` (the runs concatenate to the same text) but not to a Vera program that matches on the ADT, which is what makes it a §12.9.3 violation rather than a cosmetic one. The rest are render-visible: emphasis/strong scanning (`***both***` renders `**both****` natively, `***both***` in the browser); list-continuation indent width, where the reference strips exactly two (or three) characters and the browser strips all leading whitespace (`- a\n b` → `- a b` vs `- a b`); a `+` bullet, unrecognised in the browser; an `n)` ordered marker, likewise; a loose list, one list natively and two in the browser (`- a\n\n- b`); nesting past two levels, flattened in the browser (`- a\n - b\n - c`); a thematic break with internal spaces (`* * *`); and a table without a separator row (`\| a \| b \|\n- li`). Neither implementation is the specification — §9.7.3 pins the ADT, not the grammar that produces it — so closing this means choosing a parse for each class and stating it, then mirroring. Successor to [#1294](https://github.com/aallan/vera/issues/1294), which closed the `md_render` half; the parity suite pins every shape the two implementations do agree on, so the set cannot widen unnoticed. | [#1301](https://github.com/aallan/vera/issues/1301) | | Codegen's `_known_fns` mirrors the flat `_fn_sigs`, which keys a name the call site **cannot see** under its bare name, so the #1284 ownership predicate answers "user-owned" at a site where the checker resolved the effect operation — one rule, two tables that disagree. Three demonstrated routes: a `private fn get` in an imported module (still reachable, so still emitted); a `public fn get` a selective import excludes; and a `where` helper of a **`forall` parent**, which keeps a bare `_fn_sigs` key beside its clone-qualified one (`helper`/`id` in `ch09_generic_where_helper.vera`) where a non-generic parent's helper does not. All three are check-green, and how the failure lands depends on the route and on the widths: the two import routes are the dangerous ones — where both cells are i64 the module loads and returns the invisible helper's value instead of the cell's (**silent wrong value**), and where the widths differ it fails to load (`type mismatch: expected i64, found i32`). The generic-`where` route is always **loud**: the bare key exists in the signature table while no bare symbol is emitted (the helper is only ever `holder$Bool$where$get`), so the call fails at WAT assembly with `unknown func: failed to find name $get` and no E-code. The checker's answer is nailed by a type oracle rather than asserted — the invisible `get` returns `@Bool` while the caller returns `@Int` from `get(())` and checks green, which is only possible if the checker typed the call from the `State` cell — and each route carries a rename control that returns the checker's value. Pre-existing, not introduced by the #1284 fix, which made the rule one function without making either table scope-accurate. Fix direction: `_known_fns` becomes the names visible in the compiling declaration's **lexical** scope — module scope is not enough, since a generic's `where` helper *is* in the module and still is not in a sibling's scope — or the flattening stops keying invisible names bare, the `mod$…` renaming `_resolve_module_call_wasm_name` already applies to shadowed generics being the precedent. Same module-namespace table family as [#1281](https://github.com/aallan/vera/issues/1281). Spec §7.4 states the language rule ("declarations first — a property of the call site's scope alone"); this row tracks codegen's violation of it. | [#1299](https://github.com/aallan/vera/issues/1299) | | A postcondition may name a `State` the function's effect row never declares, and `vera check` accepts it: `ensures(new(State) == false)` under `effects(>)` reports OK, then `vera compile` fails with **E699** — the internal-compiler-error diagnostic whose own text says the type checker should have rejected the input, which is exactly the situation. Both forms land there for the same reason (no cell of that family exists, so `old()` finds no snapshot local and, since [#1285](https://github.com/aallan/vera/issues/1285), `new()` finds no getter). Loud and never a wrong answer, so this is diagnostic quality rather than soundness — but it is a check-green program that cannot compile, reported against the compiler instead of against the program, with a bug-report request the user should not act on. Before #1285 the `new()` side was worse than loud: the name-keyed lookup found the row's other getter and silently read the wrong cell. Fix direction: validate an `OldExpr`/`NewExpr`'s effect reference against the declared row where the checker already validates the rest of the clause, one rule for both forms; `test_a_family_the_row_does_not_declare_is_loud_on_both_sides` pins today's E699 and is the test to flip. | [#1298](https://github.com/aallan/vera/issues/1298) | | Spec §1.4 says its reserved keywords MUST NOT be used as function names, and for **seventeen of them nothing enforces it**: `then`, `else`, `data`, `type`, `module`, `import`, `public`, `private`, `requires`, `ensures`, `invariant`, `decreases`, `effect`, `with`, `in`, `where` and `pure` all declare and pass `vera check` as `public fn (@Int -> @Int)`. (`handle` is an eighteenth acceptance and is the sanctioned host-entry-point carve-out, §5.2; the other eleven names — `resume`, `exists`, `forall`, `match`, `assert`, `assume`, `fn`, `let`, `if`, `true`, `false` — are refused **E153**.) They are not even traps: `private fn with(@Int -> @Int)` and its `where` / `type` / `pure` / `requires` / `import` siblings are **callable** from the same file, so the declaration resolves and runs like any other function. The comment above `_KEYWORD_FN_NAMES` in `vera/checker/registration.py` gives the opposite as the reason those names are absent from the set — that the contextual lexer "does not admit them as a function name, so no declaration reaches this checker at all" — so the omission rests on a premise the tree refutes, and neither the parser nor the checker holds the MUST. Two resolutions, and the choice is a ruling rather than a mechanical fix: extend `_KEYWORD_FN_NAMES` so the seventeen join `resume` under E153, or narrow §1.4's MUST to the names actually reserved. The `resume` precedent leans to enforcing — `resume` is likewise not a keyword token anywhere and likewise parses, and was reserved *at the checker* precisely because the parser had no keyword to refuse it with — and E153's own rationale, that a keyword names one construct and nothing else (DESIGN principle 3), does not depend on the name being a trap. Until the ruling lands, §1.4's `handle` carve-out sentence deliberately makes no enforcement claim, so the spec does not assert a MUST it cannot back. | [#1296](https://github.com/aallan/vera/issues/1296) | diff --git a/README.md b/README.md index 76f22252..0d32bcfa 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,7 @@ vera effects --json # list the effect and ability registry vera errors --json # list the diagnostic-code registry: E001–E702 + W001/W002 (no file needed) ``` -`vera compile --target browser` produces a self-contained bundle (wasm + JS runtime + HTML) that runs in any browser — no build step, no bundler. Mandatory parity tests ensure identical behaviour between the command-line and browser runtimes for the pure-language surface (arithmetic, ADTs, pattern matching, closures, contracts, effects-as-host-imports, etc.). Two operations on that surface reach identity by emitting a canonical form the specification states rather than by the hosts happening to agree — `json_stringify` (spec §9.7.1) and `md_render` (§9.7.3) — so their tests assert the expected string as well as cross-host equality. One operation still falls short: the two hand-written `md_parse` implementations disagree on how a paragraph's plain-text runs are grouped and on a handful of block markers the §9.7.3 subset does not pin, so the parity suite covers the shapes they do agree on and the rest is a tracked bug (**[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**). Distinct from that: `Inference.complete`, `DB.query` and `DB.execute` return `Err` from every browser call by definition of the target, because the credential each needs would be readable from page source — reach them through a server-side endpoint called with `Http`, which does run in the browser. The IO surface is the other documented exception: terminal Vera programs that rely on `IO.sleep` for animation pacing or ANSI escape codes for cursor control compile cleanly to `--target browser` but render the escapes as literal text and freeze the tab while sleeping — the browser target expects Vera to be the pure simulation core and JavaScript to drive timing and rendering ([SKILL.md §Browser compilation](SKILL.md#browser-compilation) has the recommended pattern). +`vera compile --target browser` produces a self-contained bundle (wasm + JS runtime + HTML) that runs in any browser — no build step, no bundler. Mandatory parity tests ensure identical behaviour between the command-line and browser runtimes for the pure-language surface (arithmetic, ADTs, pattern matching, closures, contracts, effects-as-host-imports, etc.). Two operations on that surface reach identity by emitting a canonical form the specification states rather than by the hosts happening to agree — `json_stringify` (spec §9.7.1) and `md_render` (§9.7.3) — so their tests assert the expected string as well as cross-host equality. One operation still falls short: the two hand-written `md_parse` implementations disagree on how a paragraph's plain-text runs are grouped and on a handful of block markers the §9.7.3 subset does not pin, so the parity suite covers the shapes they do agree on and the rest is a tracked bug ([#1301](https://github.com/aallan/vera/issues/1301)). Distinct from that: `Inference.complete`, `DB.query` and `DB.execute` return `Err` from every browser call by definition of the target, because the credential each needs would be readable from page source — reach them through a server-side endpoint called with `Http`, which does run in the browser. The IO surface is the other documented exception: terminal Vera programs that rely on `IO.sleep` for animation pacing or ANSI escape codes for cursor control compile cleanly to `--target browser` but render the escapes as literal text and freeze the tab while sleeping — the browser target expects Vera to be the pure simulation core and JavaScript to drive timing and rendering ([SKILL.md §Browser compilation](SKILL.md#browser-compilation) has the recommended pattern). `vera compile --target wasi-p2` emits an **experimental WASI Preview 2 target (IO and Random surface)**: a binary WebAssembly component whose host imports are implemented over WASI 0.2 interfaces, runnable by any stock wasip2 host (`wasmtime run` needs no flags and no Vera bindings). Programs using host families beyond IO/Random are rejected with a diagnostic naming the family — never silently compiled against the core target. See [spec chapter 13](spec/13-wasi.md) for the architecture, the supported surface, and the documented divergences (WASI 0.2's ok/err-only exit codes, no structured trap frames across the component boundary). With `--world server`, the same contract-verified `handle(Request -> Response)` program `vera serve` hosts natively compiles to a `wasi:http/incoming-handler` component that stock `wasmtime serve` runs unmodified — verified HTTP handlers as a portable deployment artifact (`--world server` is only valid together with `--target wasi-p2`; the CLI rejects other combinations). diff --git a/TESTING.md b/TESTING.md index 38204bdb..9415e754 100644 --- a/TESTING.md +++ b/TESTING.md @@ -196,7 +196,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_tester_coverage.py` | 49 | 1,395 | Tester coverage gaps: String/Float64/ADT parameter input generation, Bool/Byte parameters, unsatisfiable preconditions, type expression edge cases, FP model-value extraction (NaN/Inf/signed-zero, #797), the #1208 naming pin (the threaded alias environment canonicalizes a type-argument alias), and the #1216 resolution set — an alias-typed parameter is trialed rather than skipped, its alias-spelled `requires` constrains the generated inputs, a refined alias reaches Z3 with its predicate so no trial violates codegen's entry guard, a `forall` variable shadowing a same-named module alias still resolves to an unsupported type variable, and a parameter that resolves to a non-encodable type still skips with that type named; plus the #1229 set — an UNTRANSLATABLE input constraint skips the function naming the blocking conjunct instead of scoring the resulting entry-guard trap as a contract failure, on the reported `string_length` repro, on a mixed clause (only the untranslatable conjunct is named), on a quantified precondition and on a refined parameter's predicate, with a Tier-3 control that must still be tested so a skip taxonomy firing on everything cannot pass | | `test_markdown.py` | 78 | 496 | Markdown parser: block/inline parsing, rendering, round-trips, edge cases | | `test_lsp.py` | 146 | 2470 | LSP transport + coordinate layer (#222 Phase C) and language features (#222 Phase D): parametrized code-point↔UTF-16 goldens incl. astral-plane fixtures and surrogate-pair snapping, Span (1-based, exclusive-end) and SourceLocation (0-based col) → LSP Range conversions, point→token-range widening, DocumentStore open/change/close + index invalidation, an in-process handler-drive test, and one stdio end-to-end round-trip against the real `vera lsp` subprocess (initialize → didOpen → shutdown → exit) pinning serverInfo + textDocumentSync capabilities; plus the Phase D feature suite — parse-error single-diagnostic path, type-error verification short-circuit, tier=3 in E520 diagnostic data, per-function tier Hint synthesis (and its suppression for functions with violated obligations), smallest-enclosing-span hover, De Bruijn slot goto (most-recent-parameter jump, out-of-range None, off-slot None, and the #1208 keying pins: a parameterised reference resolves, an alias-spelled parameter is reachable from a canonically-spelled reference, and a `forall` variable shadowing a module alias lands on the right parameter), and typed-hole completion (inside/after hole, away-from-hole None); plus the Phase E speculativeEdit suite — identical-text all-unchanged, breaking edit surfaces newly_undischarged (violated nat_sub) with canonical state untouched, strengthening edit surfaces newly_discharged, parse/type errors report ok:false, deleted functions report removed, proof_delta purity; plus the Phase F1 proposeEdit suite — the apply gate (clean and strengthening edits apply, breaking and non-compiling edits refuse), force overriding both gates with the delta still reported, wiring against a structural fake server (apply round-trip with exact full-document replacement range, refuse touches no canonical state, unopened-URI clamp sentinel), and full-document-range goldens (trailing-newline virtual line, UTF-16 end column); plus the Phase F2 strengthenContract suite — splice goldens (first-clause-only replacement with byte-identical remainder, ensures variant, unknown-fn None), the call-site audit pin (tightened precondition refused with newly_undischarged call_pre items, canonical state untouched), provable-ensures strengthening applies, and the three splice-target refusal paths (no analysis, unparseable document, unknown function); plus the Phase F3 addEffect suite — transitive-caller closure goldens (diamond in declaration order, leaf, unknown-fn None, recursion appears once), handler bounding (#725: a caller discharging the effect around its only call site drops out of the closure and is not rewritten, while a second unhandled path, a call in a handler clause, a handler for another effect, a handler naming a different instance of the same effect (`handle[State]` against a `State` propagation, which the checker does not discharge — end-to-end that caller must still be rewritten and the candidate must still apply, with the matching `State` propagation against the same fixture as the positive control that this handler key does prune something), and a bare `where`-helper call all keep it in, as does a refinement type argument at either depth (`Exn<{ @Int \| p }>` and `Exn>` render as their bare base type but discharge nothing of `Exn` — also pinned end-to-end), while an unparameterised `handle[IO]` does bound an `IO` propagation, a whitespace-spelled `State< Int >` request still bounds, and nesting bounds in either order (a matching handler inside a foreign one, a foreign one inside a matching one) — plus a key-level pin that `handle[Mod.IO]` keeps its module, and the effect-less query pinned handler-unaware; plus the two boundary pins the review added — a call in the handler's STATE INITIALISER keeps its edge, since the initialiser is evaluated in the enclosing scope before the handler is installed (pinned beside the E125 the checker raises there against a `pure` caller, with the identical call in the handler body clean as the contrast), and an alias-spelled handler does not bound a `State` propagation though the checker discharges it (`handle[State]` with `type MyAlias = Int` — the spelling comparison's under-prune, #1292, with the alias-spelled request as the control that does prune)), effect-row rewrite goldens (pure to singleton set, source-preserving append, already-present None, base-name identity blocking State next to State), diamond propagation applying one multi-site candidate with the bystander untouched, mixed append/replace rows with already-satisfied callers skipped, the fully-satisfied no-op shape, and the two refusal paths; plus the #728 instruction-contract suite — the LSP message carries description, rationale, and the Fix: paragraph (also pinning single E501 emission at the LSP surface), and a bare diagnostic maps to the description alone | -| `test_browser.py` | 274 | 4,356 | Browser parity: Python/wasmtime vs Node.js/JS-runtime output equivalence across IO, State, contracts, Markdown, Regex, and the examples the browser target can execute (two explicit lists in the file, not the whole `examples/` directory — interactive stdin, file IO, `DB` and the non-standalone `modules` example are excluded with their reasons recorded); plus the #349 `runtime.mjs` coverage battery — per-value-type and per-key-type Map variants, per-element-type Set variants, cold `Decimal` branches (exact-zero sign, negative-shift division, `decRoundPlaces` special cases, non-finite storage), `readJson`/`json_stringify` across every Json ADT tag, the Regex/Json `Result.Err` arms, and nested-Markdown walks. Two operations carry a canonical form the specification states rather than merely agreeing across the hosts, so their batteries assert more than equality: `json_stringify` (spec §9.7.1) pins the expected string on every Json ADT tag and on the number-rendering boundaries, checks three-pass idempotence, checks that a non-finite `JNumber` fails on both hosts *and* prints nothing, and checks the reference host's own number rendering differentially against a real `JSON.stringify` over doubles drawn from raw bit patterns; `md_render` (§9.7.3) pins the expected render, re-renders it to prove the fixed point, runs the round-trip property over a corpus carrying the container and multi-line shapes a flat corpus misses, and renders `MdBlock` values the test builds directly, since several renderer rules — a container's child separator, an empty container, a code span wider than one backtick — are unreachable through `md_parse`. `md_parse` itself is not yet at parity and its remaining divergence classes are a tracked bug (**[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**); the suite pins the shapes the two implementations agree on | +| `test_browser.py` | 274 | 4,356 | Browser parity: Python/wasmtime vs Node.js/JS-runtime output equivalence across IO, State, contracts, Markdown, Regex, and the examples the browser target can execute (two explicit lists in the file, not the whole `examples/` directory — interactive stdin, file IO, `DB` and the non-standalone `modules` example are excluded with their reasons recorded); plus the #349 `runtime.mjs` coverage battery — per-value-type and per-key-type Map variants, per-element-type Set variants, cold `Decimal` branches (exact-zero sign, negative-shift division, `decRoundPlaces` special cases, non-finite storage), `readJson`/`json_stringify` across every Json ADT tag, the Regex/Json `Result.Err` arms, and nested-Markdown walks. Two operations carry a canonical form the specification states rather than merely agreeing across the hosts, so their batteries assert more than equality: `json_stringify` (spec §9.7.1) pins the expected string on every Json ADT tag and on the number-rendering boundaries, checks three-pass idempotence, checks that a non-finite `JNumber` fails on both hosts *and* prints nothing, and checks the reference host's own number rendering differentially against a real `JSON.stringify` over doubles drawn from raw bit patterns; `md_render` (§9.7.3) pins the expected render, re-renders it to prove the fixed point, runs the round-trip property over a corpus carrying the container and multi-line shapes a flat corpus misses, and renders `MdBlock` values the test builds directly, since several renderer rules — a container's child separator, an empty container, a code span wider than one backtick — are unreachable through `md_parse`. `md_parse` itself is not yet at parity and its remaining divergence classes are a tracked bug ([#1301](https://github.com/aallan/vera/issues/1301)); the suite pins the shapes the two implementations agree on | | `test_conformance.py` | 1080 | 125 | Parametrized conformance suite: parse, check, verify, run, format idempotency across 216 programs | | `test_prelude.py` | 29 | 585 | Prelude injection: Option/Result/array operation detection, combinator shadowing, type aliases, the reserved namespace every injected alias declaration lives in — checked against the checker's own E154 regex rather than a second spelling of the rule, since an alias the prelude injects outside it is one codegen resolves and the checker leaves opaque (#1184/#1221) — end-to-end compilation | | `test_checker_apply_fn.py` | 18 | 455 | #854 — `apply_fn` as a checker special form: zero-warning pins (API + CLI `--json` + closures.vera), E201 arity / E202 type / non-function-first-arg errors, E122/E125 effect-row enforcement for applied fn values, E151 redefinition rejection, variadic two-param application, prelude combinator regression pins | diff --git a/docs/index.html b/docs/index.html index e9466fc1..abe6ded7 100644 --- a/docs/index.html +++ b/docs/index.html @@ -576,7 +576,7 @@

Browser

runtime.mjs index.html
-

Self-contained — no bundler. Serve with any HTTP server (python -m http.server). IO.print writes to the page; every other operation the browser target supports works identically to the CLI, apart from md_parse, whose two hand-written implementations still disagree on a few shapes the §9.7.3 subset does not pin (a tracked bug). json_stringify and md_render reach that identity by emitting a canonical form the specification states (§9.7.1, §9.7.3) rather than by the hosts happening to agree, so parity tests check them against that form as well as against each other, on every PR. Note: Inference.complete and every DB operation return an error in the browser — a deliberate platform boundary, since the credentials they need would be readable from page source; reach them through a server-side proxy via Http.

+

Self-contained — no bundler. Serve with any HTTP server (python -m http.server). IO.print writes to the page; every other operation the browser target supports works identically to the CLI, apart from md_parse, whose two hand-written implementations still disagree on a few shapes the §9.7.3 subset does not pin (#1301). json_stringify and md_render reach that identity by emitting a canonical form the specification states (§9.7.1, §9.7.3) rather than by the hosts happening to agree, so parity tests check them against that form as well as against each other, on every PR. Note: Inference.complete and every DB operation return an error in the browser — a deliberate platform boundary, since the credentials they need would be readable from page source; reach them through a server-side proxy via Http.

WASI components

diff --git a/docs/index.md b/docs/index.md index 5106340a..2970e4ef 100644 --- a/docs/index.md +++ b/docs/index.md @@ -217,7 +217,7 @@ Browser bundle: examples/hello_world_browser/ index.html ``` -Self-contained — no bundler. Serve with any HTTP server (`python -m http.server`). `IO.print` writes to the page; every other operation the browser target supports works identically to the CLI, apart from `md_parse`, whose two hand-written implementations still disagree on a few shapes the §9.7.3 subset does not pin (a tracked bug). `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states (§9.7.1, §9.7.3) rather than by the hosts happening to agree, so parity tests check them against that form as well as against each other, on every PR. *Note: `Inference.complete` and every `DB` operation return an error in the browser — a deliberate platform boundary, since the credentials they need would be readable from page source; reach them through a server-side proxy via `Http`.* +Self-contained — no bundler. Serve with any HTTP server (`python -m http.server`). `IO.print` writes to the page; every other operation the browser target supports works identically to the CLI, apart from `md_parse`, whose two hand-written implementations still disagree on a few shapes the §9.7.3 subset does not pin ([#1301](https://github.com/aallan/vera/issues/1301)). `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states (§9.7.1, §9.7.3) rather than by the hosts happening to agree, so parity tests check them against that form as well as against each other, on every PR. *Note: `Inference.complete` and every `DB` operation return an error in the browser — a deliberate platform boundary, since the credentials they need would be readable from page source; reach them through a server-side proxy via `Http`.* ### WASI components diff --git a/docs/llms-full.txt b/docs/llms-full.txt index a2ff350f..fb6c9494 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -3147,7 +3147,7 @@ vera compile --target browser examples/hello_world.vera # index.html ``` -Serve it with any HTTP server and open `index.html` — no build step, no bundler, no dependencies. The JavaScript runtime provides browser-appropriate implementations of all Vera host bindings: `IO.print` writes to the page, `IO.read_line` uses `prompt()`, and all other operations (State, contracts, JSON, Markdown rendering) work identically to the wasmtime runtime. `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states — §9.7.1 and §9.7.3 — rather than by the two hosts happening to agree, which is what the parity suite checks them against. `md_parse` is the exception: the two hand-written parsers still disagree on how a paragraph's plain-text runs are grouped — invisible to `md_render`, since they concatenate to the same text — and on a few block markers the §9.7.3 subset leaves open, such as a `+` bullet or a list nested more than two deep. That is a tracked bug, listed in [KNOWN_ISSUES.md](https://github.com/aallan/vera/blob/main/KNOWN_ISSUES.md). +Serve it with any HTTP server and open `index.html` — no build step, no bundler, no dependencies. The JavaScript runtime provides browser-appropriate implementations of all Vera host bindings: `IO.print` writes to the page, `IO.read_line` uses `prompt()`, and all other operations (State, contracts, JSON, Markdown rendering) work identically to the wasmtime runtime. `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states — §9.7.1 and §9.7.3 — rather than by the two hosts happening to agree, which is what the parity suite checks them against. `md_parse` is the exception: the two hand-written parsers still disagree on how a paragraph's plain-text runs are grouped — invisible to `md_render`, since they concatenate to the same text — and on a few block markers the §9.7.3 subset leaves open, such as a `+` bullet or a list nested more than two deep. That is a tracked bug ([#1301](https://github.com/aallan/vera/issues/1301)). Two effects are refused outright rather than merely differing. `Inference` and `DB` return an explanatory `Err` from every operation in the browser, because the API key or database credential they would need is readable from page source and network traffic in client-side JavaScript. Reach them through a server-side endpoint and call it with `Http`, which does run in the browser — it is backed by `XMLHttpRequest`, not a stub. That refusal is a deliberate platform boundary; spec §9.5.5 states it for `Inference`. diff --git a/scripts/build_site.py b/scripts/build_site.py index d522f111..5164c9e3 100644 --- a/scripts/build_site.py +++ b/scripts/build_site.py @@ -615,7 +615,7 @@ def build_index_md(version: str) -> str: index.html ``` -Self-contained — no bundler. Serve with any HTTP server (`python -m http.server`). `IO.print` writes to the page; every other operation the browser target supports works identically to the CLI, apart from `md_parse`, whose two hand-written implementations still disagree on a few shapes the §9.7.3 subset does not pin (a tracked bug). `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states (§9.7.1, §9.7.3) rather than by the hosts happening to agree, so parity tests check them against that form as well as against each other, on every PR. *Note: `Inference.complete` and every `DB` operation return an error in the browser — a deliberate platform boundary, since the credentials they need would be readable from page source; reach them through a server-side proxy via `Http`.* +Self-contained — no bundler. Serve with any HTTP server (`python -m http.server`). `IO.print` writes to the page; every other operation the browser target supports works identically to the CLI, apart from `md_parse`, whose two hand-written implementations still disagree on a few shapes the §9.7.3 subset does not pin ([#1301](https://github.com/aallan/vera/issues/1301)). `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states (§9.7.1, §9.7.3) rather than by the hosts happening to agree, so parity tests check them against that form as well as against each other, on every PR. *Note: `Inference.complete` and every `DB` operation return an error in the browser — a deliberate platform boundary, since the credentials they need would be readable from page source; reach them through a server-side proxy via `Http`.* ### WASI components diff --git a/spec/12-runtime.md b/spec/12-runtime.md index 0af289b6..4f022c33 100644 --- a/spec/12-runtime.md +++ b/spec/12-runtime.md @@ -662,7 +662,7 @@ Across the surface the two runtimes actually share — State, contracts, JSON, ` Two operations reach that identity by carrying a canonical form the specification states rather than by both hosts happening to agree. `json_stringify` emits the one form §9.7.1 pins, down to separators and number rendering, and refuses a non-finite number on both hosts instead of one refusing and the other substituting `null`. `md_render` emits the canonical Markdown §9.7.3 describes — soft line breaks collapsed, each container's prefix re-applied to every line of every child, children separated, code spans fenced wider than their content — which is what makes the render a fixed point on both hosts. -`md_parse` is the one operation on the shared surface where the requirement is not yet met. The two implementations are hand-written parsers for the §9.7.3 subset, and they still disagree on inputs the subset does not pin: how adjacent plain-text runs are grouped inside a paragraph (the largest class by count, and invisible to `md_render` because the runs concatenate to the same text), and a handful of block markers — a `+` bullet, an `n)` ordered marker, a list item separated from the next by a blank line, a list nested more than two deep, a thematic break written with internal spaces, and a table without a separator row. The divergence is a tracked bug, not part of the browser target's definition; the parity suite covers `md_parse` on every shape it does agree on, so the set cannot widen unnoticed. +`md_parse` is the one operation on the shared surface where the requirement is not yet met. The two implementations are hand-written parsers for the §9.7.3 subset, and they still disagree on inputs the subset does not pin: how adjacent plain-text runs are grouped inside a paragraph (the largest class by count, and invisible to `md_render` because the runs concatenate to the same text), and a handful of block markers — a `+` bullet, an `n)` ordered marker, a list item separated from the next by a blank line, a list nested more than two deep, a thematic break written with internal spaces, and a table without a separator row. The divergence is a tracked bug ([#1301](https://github.com/aallan/vera/issues/1301)), not part of the browser target's definition; the parity suite covers `md_parse` on every shape it does agree on, so the set cannot widen unnoticed. Fused async preserves the underlying value whenever the two hosts' results are comparable. Only the evaluation strategy differs, which is spec-conformant — §9.5.4 says an implementation MAY evaluate `async(e)` concurrently — and any difference that remains comes from the `Http` outcome underneath rather than from `async` itself. Mandatory parity tests enforce identical results across the shared surface. diff --git a/vera/README.md b/vera/README.md index 68d1665e..1d542a41 100644 --- a/vera/README.md +++ b/vera/README.md @@ -599,7 +599,7 @@ The WASM import interface is the portability contract: the compiled `.wasm` bina **GC reachability discipline (JS host side):** JS host functions that allocate multiple WASM heap blocks and hold intermediates in JS locals must root those intermediates on the shadow stack — otherwise EAGER_GC (and, under pressure, normal GC) reclaims them mid-walk. The runtime exports two helpers: `gcShadowPush(ptr)` writes a pointer to `$gc_sp` and advances it (throws if `$gc_sp` / `$gc_stack_limit` aren't exported, since that means the module was built without GC support but is calling allocators that can trigger GC), and `gcGuard(fn)` saves `$gc_sp` at entry and restores it on exit (success or exception). This is the browser parallel of the CLI-side `_ShadowGuard` context manager added in v0.0.158 (#692). The walkers `writeJson` / `writeHtml` and the parsers `json_parse` / `html_parse` wrap their bodies in `gcGuard` and push intermediates (`arrPtr`, `wrapperPtr`, `jsonPtr`) as soon as each is allocated — see `runtime.mjs` for the canonical pattern. Without this, `Map` / `Set` and similar heap-pointer-keyed collections drop values under GC pressure (#708). -**Parity enforcement:** `tests/test_browser.py` runs the examples the browser target can execute — two explicit lists in that file, not the whole `examples/` directory, since an example that reads stdin interactively, uses a refused host family (file IO, `DB`), or does not compile standalone cannot be compared — plus per-binding batteries over the Map/Set/Decimal/Json/Regex/Markdown host imports, through both Python/wasmtime and Node.js/JS-runtime. The two example lists carry different oracles: the examples exporting `main` are run and compared on stdout, while the ones reached as exported functions are called with fixed arguments and compared on the returned value. The per-binding batteries compare stdout. `json_stringify` and `md_render` are compared the same way and additionally against the canonical form the specification states for each (§9.7.1, §9.7.3), because cross-host equality alone would be satisfied by two hosts agreeing on a wrong answer; `md_render` is also asserted stable under re-render and exercised on `MdBlock` values the test *builds*, since several renderer rules are unreachable through `md_parse`, and `json_stringify`'s number rendering is checked differentially against a real `JSON.stringify`. `md_parse` itself is the one shared operation still divergent between the hosts — plain-text run grouping inside a paragraph, and a handful of block markers §9.7.3 does not pin — tracked as a bug in `KNOWN_ISSUES.md`; the suite pins the shapes the two do agree on. The browser stubs are covered on two different shapes: `IO.read_file` and `IO.write_file` get the same per-host pinning, run through both runtimes against a path that really is readable or writable so the native `Ok` and the browser `Err` are each asserted (a missing file or an unwritable directory would `Err` on both sides and prove nothing), while `IO.read_char` is exercised in Node alone — the module links and the stub's `Err` arm returns `0` — with no native run to compare against. `Inference` and `DB` return `Err` from every browser operation, which is a deliberate platform boundary — the credentials they need would be readable from page source — rather than a divergence awaiting a fix. Pre-commit hooks and CI trigger these tests on any change to the host binding surface. +**Parity enforcement:** `tests/test_browser.py` runs the examples the browser target can execute — two explicit lists in that file, not the whole `examples/` directory, since an example that reads stdin interactively, uses a refused host family (file IO, `DB`), or does not compile standalone cannot be compared — plus per-binding batteries over the Map/Set/Decimal/Json/Regex/Markdown host imports, through both Python/wasmtime and Node.js/JS-runtime. The two example lists carry different oracles: the examples exporting `main` are run and compared on stdout, while the ones reached as exported functions are called with fixed arguments and compared on the returned value. The per-binding batteries compare stdout. `json_stringify` and `md_render` are compared the same way and additionally against the canonical form the specification states for each (§9.7.1, §9.7.3), because cross-host equality alone would be satisfied by two hosts agreeing on a wrong answer; `md_render` is also asserted stable under re-render and exercised on `MdBlock` values the test *builds*, since several renderer rules are unreachable through `md_parse`, and `json_stringify`'s number rendering is checked differentially against a real `JSON.stringify`. `md_parse` itself is the one shared operation still divergent between the hosts — plain-text run grouping inside a paragraph, and a handful of block markers §9.7.3 does not pin — tracked as [#1301](https://github.com/aallan/vera/issues/1301); the suite pins the shapes the two do agree on. The browser stubs are covered on two different shapes: `IO.read_file` and `IO.write_file` get the same per-host pinning, run through both runtimes against a path that really is readable or writable so the native `Ok` and the browser `Err` are each asserted (a missing file or an unwritable directory would `Err` on both sides and prove nothing), while `IO.read_char` is exercised in Node alone — the module links and the stub's `Err` arm returns `0` — with no native run to compare against. `Inference` and `DB` return `Err` from every browser operation, which is a deliberate platform boundary — the credentials they need would be readable from page source — rather than a divergence awaiting a fix. Pre-commit hooks and CI trigger these tests on any change to the host binding surface. `browser/emit.py` provides `emit_browser_bundle()` for the `vera compile --target browser` CLI command, which produces a ready-to-serve directory (module.wasm + vera-runtime.mjs + index.html). From 0d07c46b9e42cd2fd34330a8698bf28148a5dc5d Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 01:49:11 +0100 Subject: [PATCH 09/69] Scope the Markdown parity sentences to what is measured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four wording corrections in the two spec chapters this branch touched, each checked against the review's measurements rather than reasoned from the code. §12.9.3 claimed the parity suite covers md_parse "on every shape it does agree on". It does not: the corpus is 32 entries where the hosts agree on 1,260 of 1,471 measured shapes. That is the same overclaim shape that let the blockquote separator bug sit unnoticed behind a round-trip corpus with no multi-child container in it, so it now reads as README.md already did — "the shapes they do agree on" — and the trailing clause drops the matching claim that the divergent set cannot widen unnoticed, which the same arithmetic refutes. It says what is true instead: a regression on a covered shape goes red. §12.9.6 said the suite catches drift "across the whole shared surface", which contradicts §12.9.3's md_parse carve-out one section earlier. Qualified in place, naming what the corpus does not carry rather than implying those shapes are pinned per host — they are not pinned at all. §12.9.3's class list named seven of #1301's nine, and the two it left out are both render-VISIBLE, so the list read as though everything visible were a block marker. Emphasis and strong scanning, and the indentation a list-continuation line loses, are now named alongside them, and the paragraph is reorganised so the invisible class is separated from the visible ones instead of leading a flat list. §9.7.3's code-span rule named the fence-width collision as the only round-trip loss. Two more are pre-existing and parity-clean, and both were verified on both hosts before being written down: an empty span, whose rendering reads back as literal text, and a span whose content begins and ends with a space, which a parser strips unconditionally. All three are stated after the fixed-point sentence they qualify, and as "lost identically on every runtime" — the distinction that keeps them out of §12.9.3's scope. Skip-changelog: wording only, of behaviour the [Unreleased] bullets already describe. Co-Authored-By: Claude --- spec/09-standard-library.md | 4 +++- spec/12-runtime.md | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/spec/09-standard-library.md b/spec/09-standard-library.md index 00c55621..b4bd2deb 100644 --- a/spec/09-standard-library.md +++ b/spec/09-standard-library.md @@ -2274,10 +2274,12 @@ Four rules carry that property. The first two follow from the ADT having no line - **A paragraph is one line.** Its inline content is rendered without internal newlines. A parser collapses a paragraph's soft line breaks to spaces on the way in, so nothing survives to be re-emitted. - **A container prefixes every line of every child.** A block quote writes `> ` before each rendered line and a bare `>` for a blank one; a list item writes its marker before the first line and an equal-width indent before the rest. Prefixing only a child's first line would leave a fenced block's body — or a nested block's continuation — outside its container, and the next `md_parse` would read it as a sibling. - **A container separates its children.** A block quote writes a bare `>` between adjacent children, as a document writes a blank line between its own. Without it two quoted paragraphs render as two adjacent quoted lines, which re-parse as one paragraph. An empty container still occupies its line: a `MdBlockQuote` with no children renders `>`, because rendering it as nothing deletes the block and leaves the enclosing separator dangling. -- **A code span is fenced longer than its content.** The fence is one backtick more than the longest backtick run inside the span, with a single padding space on each side when the content itself starts or ends with a backtick. A fixed-width fence terminates on a run inside the content. (A span needing three or more backticks is not representable at the start of a line, where that run is a fenced-code-block opener; there is no escape syntax in this subset.) +- **A code span is fenced longer than its content.** The fence is one backtick more than the longest backtick run inside the span, with a single padding space on each side when the content itself starts or ends with a backtick. A fixed-width fence terminates on a run inside the content. Together these should make `md_render` a fixed point: re-parsing and re-rendering its output returns the same bytes. +Three code-span shapes are outside that, and are lost identically on every runtime rather than differently, because the subset has no escape syntax to write them another way: a span needing three or more backticks, at the start of a line, where that run is a fenced-code-block opener; an empty span, whose rendering reads back as literal text; and a span whose content itself begins and ends with a space, since a parser strips one such pair unconditionally. + **Accessor functions for contracts:** diff --git a/spec/12-runtime.md b/spec/12-runtime.md index 4f022c33..0cf505a1 100644 --- a/spec/12-runtime.md +++ b/spec/12-runtime.md @@ -662,7 +662,7 @@ Across the surface the two runtimes actually share — State, contracts, JSON, ` Two operations reach that identity by carrying a canonical form the specification states rather than by both hosts happening to agree. `json_stringify` emits the one form §9.7.1 pins, down to separators and number rendering, and refuses a non-finite number on both hosts instead of one refusing and the other substituting `null`. `md_render` emits the canonical Markdown §9.7.3 describes — soft line breaks collapsed, each container's prefix re-applied to every line of every child, children separated, code spans fenced wider than their content — which is what makes the render a fixed point on both hosts. -`md_parse` is the one operation on the shared surface where the requirement is not yet met. The two implementations are hand-written parsers for the §9.7.3 subset, and they still disagree on inputs the subset does not pin: how adjacent plain-text runs are grouped inside a paragraph (the largest class by count, and invisible to `md_render` because the runs concatenate to the same text), and a handful of block markers — a `+` bullet, an `n)` ordered marker, a list item separated from the next by a blank line, a list nested more than two deep, a thematic break written with internal spaces, and a table without a separator row. The divergence is a tracked bug ([#1301](https://github.com/aallan/vera/issues/1301)), not part of the browser target's definition; the parity suite covers `md_parse` on every shape it does agree on, so the set cannot widen unnoticed. +`md_parse` is the one operation on the shared surface where the requirement is not yet met. The two implementations are hand-written parsers for the §9.7.3 subset, and they still disagree on inputs the subset does not pin. One class is invisible to `md_render`: how adjacent plain-text runs are grouped inside a paragraph — the largest by count, and hidden at the render level because the runs concatenate to the same text. The rest are visible in the rendered output. Two are inline: how emphasis and strong markers are scanned when they nest or go unclosed, and how much indentation a continuation line loses inside a list item (the reference strips a fixed two or three characters, the browser all of it). The others are block markers — a `+` bullet, an `n)` ordered marker, a list item separated from the next by a blank line, a list nested more than two deep, a thematic break written with internal spaces, and a table without a separator row. The divergence is a tracked bug ([#1301](https://github.com/aallan/vera/issues/1301)), not part of the browser target's definition; the parity suite covers the shapes they do agree on, so a regression on one of those goes red. Fused async preserves the underlying value whenever the two hosts' results are comparable. Only the evaluation strategy differs, which is spec-conformant — §9.5.4 says an implementation MAY evaluate `async(e)` concurrently — and any difference that remains comes from the `Http` outcome underneath rather than from `async` itself. Mandatory parity tests enforce identical results across the shared surface. @@ -689,7 +689,7 @@ The `index.html` file uses an ES module script that imports from `vera-runtime.m ### 12.9.6 Parity Testing -The browser parity test suite (`tests/test_browser.py`) runs the examples the browser target can execute — and per-binding batteries over the Map, Set, Decimal, Json, Regex and Markdown host imports — through both the Python/wasmtime runtime and the Node.js/JS-runtime. The example corpus is two explicit lists in that file rather than the whole `examples/` directory, and the two carry different oracles: the examples exporting `main` are run and compared on stdout, while the ones reached as exported functions are called with fixed arguments and compared on the returned value. The per-binding batteries compare stdout. An example is excluded from the corpus when it reads stdin interactively, when it uses a host family the browser refuses (file IO, `DB`), or when it does not compile standalone, and each exclusion is recorded beside the list with its reason. This catches drift between the two implementations across the whole shared surface. The tests cover IO operations, State operations, contract violations, Markdown parsing/rendering, and browser bundle emission. +The browser parity test suite (`tests/test_browser.py`) runs the examples the browser target can execute — and per-binding batteries over the Map, Set, Decimal, Json, Regex and Markdown host imports — through both the Python/wasmtime runtime and the Node.js/JS-runtime. The example corpus is two explicit lists in that file rather than the whole `examples/` directory, and the two carry different oracles: the examples exporting `main` are run and compared on stdout, while the ones reached as exported functions are called with fixed arguments and compared on the returned value. The per-binding batteries compare stdout. An example is excluded from the corpus when it reads stdin interactively, when it uses a host family the browser refuses (file IO, `DB`), or when it does not compile standalone, and each exclusion is recorded beside the list with its reason. This catches drift between the two implementations across the shared surface, save for the `md_parse` shapes §12.9.3 records as already divergent, which the corpus does not carry. The tests cover IO operations, State operations, contract violations, Markdown parsing/rendering, and browser bundle emission. The two operations that carry a canonical form (§12.9.3) are tested three ways rather than by equality alone, because two hosts agreeing on a wrong answer would satisfy equality: each case asserts cross-host equality, the expected string written out, and — for `md_render` — stability under re-render, which is the observable form of §9.7.3's round-trip property. Some rules are reachable only from an `MdBlock` a program *built* rather than parsed, so the Markdown battery renders constructed values too. `json_stringify`'s number rendering is additionally checked differentially against a real `JSON.stringify` over a sample of doubles drawn from raw bit patterns, since the reference host renders numbers itself rather than delegating. Failures that are contracts rather than values are asserted two-sidedly: a non-finite `JNumber` must make the call fail on both hosts **and** produce no output, so a host that emitted `null` before failing could not read as a pass. The browser stubs are covered on two different shapes: `IO.read_file` and `IO.write_file` get per-host pinning, running both runtimes against a path that is really readable or writable so the native `Ok` and the browser `Err` are each asserted, while `IO.read_char` is checked in the browser alone — that the module links and the stub's `Err` arm is taken — with no native side to compare against. From 7e3eab014a1afd4baa56fa0da472fbb1ba223e3c Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 02:42:02 +0100 Subject: [PATCH 10/69] Carry a JSON object's key order through the browser host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Canonical key order (spec §9.7.1) is insertion order, and the browser host was losing it. It reached the WASM-side Map through ordinary JS objects on both sides of the boundary — JSON.parse returns one, writeJson enumerated it with Object.entries, readJson rebuilt one key by key — and an ordinary object cannot carry insertion order, because ES OrdinaryOwnPropertyKeys lists array-index keys first, in ascending numeric order. Measured host against host through the same .wasm: {"2":1,"1":2} round-trips to itself natively and to {"1":2,"2":1} in the browser, {"b":1,"3":2,"a":3} to {"3":2,"b":1,"a":3}. The same intermediate loses a field named __proto__ outright, which the key-order investigation turned up beside it: obj["__proto__"] = v runs Object.prototype's setter and creates no own property, so {"__proto__":{"a":1}} came back {}. Both losses are silent, and both sat inside the property the #1293 entry claims to establish. Objects are now a JS Map from parse through serialization. json_parse keeps JSON.parse as the accept/reject decision, so the Err domain and its message are untouched, and rebuilds the tree with an order-preserving re-scan that hands every leaf back to JSON.parse on its own slice rather than decoding strings and numbers a second way. json_stringify walks the result with a canonical emitter that mirrors dumps_canonical, since JSON.stringify does not know about Map — leaf rendering stays ECMAScript's, so the output form is unchanged for every value that was already correct. The new battery is seven key shapes an alphabetically-keyed object cannot express, each as a round trip and a three-pass idempotence check, plus a JObject the program builds with map_insert rather than parsing. The built one is what distinguishes the two halves: a round trip alone cannot tell "both sides fixed" from "neither", because the parse side's ascending-index order is a fixed point of the stringify side's. Reverting either half independently was confirmed to go red, and the scanner was additionally fuzzed against JSON.parse over 40,000 generated documents — compact, pretty-printed, tab-indented and whitespace-padded — with no divergence in structure, canonical output, or self-round-trip. The differential over random doubles in the same file gets the guard its docstring already claimed: a bare zip stops at the shorter input, so a truncated Node reply read as a shorter run of passes rather than a failure. Co-Authored-By: Claude --- CHANGELOG.md | 2 +- FAQ.md | 2 +- README.md | 2 +- ROADMAP.md | 2 +- TESTING.md | 4 +- docs/llms-full.txt | 2 +- tests/test_browser.py | 124 ++++++++++++++++++- vera/README.md | 2 +- vera/browser/runtime.mjs | 255 ++++++++++++++++++++++++++++++++------- 9 files changed, 344 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a104b9c8..7f8939e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **The browser runtime's `md_render` mirrors the reference renderer, holds the round-trip property, and is a fixed point** ([#1294](https://github.com/aallan/vera/issues/1294)). It preserved a paragraph's internal soft line breaks and did not re-apply a container's prefix (`> `, list-item indent) on output, so it broke the round-trip property spec §9.7.3 states for `md_render` and, with it, §12.9.3's identical-results requirement. The scope was any multi-line paragraph, not the list lazy continuation first observed, and the render was not stable: re-rendering its own output moved content out of its container (`> a b` → `> a\nb` → `> a\n\nb`, where `b` is no longer quoted), and on a blockquote wrapping a heading and a fenced block the second render fragmented the fence into three and lifted the code clean out of the quote, past recovery by any subsequent parse. Two defects underlay it, in two different phases, and both are fixed because neither alone closes the issue. The **parser** joined a paragraph's lines with `\n` where the reference parser joins with a space: §9.7.3's design note excludes hard and soft line breaks from the ADT — "collapsed into paragraph text" — so a break that survives into `MdText` is one no renderer can tell from text the author wrote, and `md_parse` itself therefore returned different ADTs on the two hosts. The **renderer** returned one string and threaded the container prefix down as an argument, which a container could only apply to the *first* line of each child; it is now line-based, mirroring `_render_block` in `vera/markdown.py`, so every caller re-applies its own prefix to every line it receives — the property that makes the render a fixed point. A third mirror lands with them: a code span containing a backtick now renders with the longer `` `` … `` `` fence, which the reference renderer has always done and neither parser can produce, so it was reachable only from a constructed ADT. Spec §9.7.3 states both rules rather than leaving them as an implementation detail two hosts had to rediscover. `tests/test_browser.py`'s two pinned-divergence assertions collapse into parity assertions, and the battery around them is now three-layered — cross-host equality, the expected string, and stability under re-render — because equality alone passes two hosts that agree on a wrong answer and a single render passes a renderer that drifts on the second pass. The §9.7.3 round-trip property is exercised over a nineteen-case corpus: the eight the reference renderer is already held to in `tests/test_markdown.py`, every one of which is single-line or fence-only and therefore blind to exactly this defect, plus the container and multi-line shapes the bug was about. Three further cases render ADTs a Vera program *built* rather than parsed, since the parser only reaches the shapes it happens to produce. +- **`json_stringify` has one canonical output form, and both runtimes produce it** ([#1293](https://github.com/aallan/vera/issues/1293)). The two hosts disagreed: the reference runtime called `json.dumps(value, ensure_ascii=False, allow_nan=False)` — `", "` / `": "` separators, and since `read_json` hands it Python `float`s, a `JNumber` parsed from `1` re-rendered as `1.0` — where the browser called bare `JSON.stringify`. Spec §12.9.3 requires every non-IO operation to produce identical results in both runtimes, so the divergence itself was the defect, and §9.7.1 now states the resolution: the compact form, `,` and `:` with no padding, object members in insertion order, strings escaped with non-ASCII emitted literally, and numbers rendered by ECMAScript's `Number::toString`. The browser already emitted that form; the reference host moved to it. Measuring the gap first showed it was wider than the issue's two axes — `json.dumps` renders floats with `repr`, hard-wired inside `json.encoder` and not reachable through any separator setting, and `repr` disagrees with `Number::toString` on **four** independent boundaries, not one: the fractional part of an integral value (`1.0` vs `1`), the threshold for exponential notation at each end of the range (`1e+16` vs `10000000000000000`, `1e-06` vs `0.000001`), and the spelling of the exponent itself (`1e-07` vs `1e-7`), plus negative zero (`-0.0` vs `0`). Fixing only the reported symptom would have left the other three diverging, so `vera/wasm/json_serde.py` gains `format_json_number`, an implementation of ECMA-262 §6.1.6.1.20 that takes its shortest-round-trip digits from `repr` and recomputes only their placement; string escaping is still delegated to `json.dumps`, which already agrees with `JSON.stringify` byte for byte. The claim "matches ECMAScript" is checked differentially against the real `JSON.stringify` over 2,000 doubles drawn from raw bit patterns, not only against a hand-written boundary table, since a table proves the cases its author thought of and those are the cases the code was written to handle. Alongside the formatting, the third asymmetry the issue folds in is closed in the other direction: a `JNumber` holding `NaN` or an infinity now **fails on both hosts** with the same sentence, where the browser used to emit `null` — swapping a value RFC 8259 cannot carry for a different, perfectly valid one that no consumer could tell from a genuine `JNull`. The eleven Node-only tag assertions in `tests/test_browser.py` become full parity assertions, the two pinned-divergence strings collapse into single-truth ones, and the battery gains the number boundaries, a three-pass idempotence check on every case, and the two-sided failure assertion for non-finite values (it must raise *and* print nothing, so a host that emitted `null` before failing cannot read as a pass). Review of the change found the browser host breaking the *insertion order* clause of that same canonical form, which no test covered because every JSON object in the suite had alphabetically-ordered, non-numeric keys: `vera/browser/runtime.mjs` reached the WASM-side `Map` through ordinary JS objects on both sides of the boundary — `JSON.parse` returns one, `writeJson` enumerated it with `Object.entries`, `readJson` rebuilt one key by key — and an ordinary object cannot carry insertion order, because ES `OrdinaryOwnPropertyKeys` lists array-index keys first in ascending numeric order. `{"2":1,"1":2}` round-tripped to `{"2":1,"1":2}` natively and `{"1":2,"2":1}` in the browser. The same intermediate lost a field named `__proto__` outright — assigning it runs `Object.prototype`'s setter and creates no own property — so `{"__proto__":{"a":1}}` came back `{}`. Both are now carried in a JS `Map` from parse through serialization: `json_parse` keeps `JSON.parse` as the accept/reject decision (so the `Err` domain and its message are unchanged) and rebuilds the tree with an order-preserving re-scan that hands every leaf back to `JSON.parse` on its own slice, and `json_stringify` walks the result with a canonical emitter mirroring `dumps_canonical` rather than calling `JSON.stringify`, which does not know about `Map`. Both losses were silent, and both sat inside the property this entry claims to establish. -- **`json_stringify` has one canonical output form, and both runtimes produce it** ([#1293](https://github.com/aallan/vera/issues/1293)). The two hosts disagreed: the reference runtime called `json.dumps(value, ensure_ascii=False, allow_nan=False)` — `", "` / `": "` separators, and since `read_json` hands it Python `float`s, a `JNumber` parsed from `1` re-rendered as `1.0` — where the browser called bare `JSON.stringify`. Spec §12.9.3 requires every non-IO operation to produce identical results in both runtimes, so the divergence itself was the defect, and §9.7.1 now states the resolution: the compact form, `,` and `:` with no padding, object members in insertion order, strings escaped with non-ASCII emitted literally, and numbers rendered by ECMAScript's `Number::toString`. The browser already emitted that form; the reference host moved to it. Measuring the gap first showed it was wider than the issue's two axes — `json.dumps` renders floats with `repr`, hard-wired inside `json.encoder` and not reachable through any separator setting, and `repr` disagrees with `Number::toString` on **four** independent boundaries, not one: the fractional part of an integral value (`1.0` vs `1`), the threshold for exponential notation at each end of the range (`1e+16` vs `10000000000000000`, `1e-06` vs `0.000001`), and the spelling of the exponent itself (`1e-07` vs `1e-7`), plus negative zero (`-0.0` vs `0`). Fixing only the reported symptom would have left the other three diverging, so `vera/wasm/json_serde.py` gains `format_json_number`, an implementation of ECMA-262 §6.1.6.1.20 that takes its shortest-round-trip digits from `repr` and recomputes only their placement; string escaping is still delegated to `json.dumps`, which already agrees with `JSON.stringify` byte for byte. The claim "matches ECMAScript" is checked differentially against the real `JSON.stringify` over 2,000 doubles drawn from raw bit patterns, not only against a hand-written boundary table, since a table proves the cases its author thought of and those are the cases the code was written to handle. Alongside the formatting, the third asymmetry the issue folds in is closed in the other direction: a `JNumber` holding `NaN` or an infinity now **fails on both hosts** with the same sentence, where the browser used to emit `null` — swapping a value RFC 8259 cannot carry for a different, perfectly valid one that no consumer could tell from a genuine `JNull`. The eleven Node-only tag assertions in `tests/test_browser.py` become full parity assertions, the two pinned-divergence strings collapse into single-truth ones, and the battery gains the number boundaries, a three-pass idempotence check on every case, and the two-sided failure assertion for non-finite values (it must raise *and* print nothing, so a host that emitted `null` before failing cannot read as a pass). ## [0.1.11] - 2026-08-13 diff --git a/FAQ.md b/FAQ.md index a46d7c2c..93a8a62b 100644 --- a/FAQ.md +++ b/FAQ.md @@ -279,7 +279,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 10,714 tests, including a 216-program conformance suite +- 10,729 tests, including a 216-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/README.md b/README.md index 0d32bcfa..5a875ccd 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ cp /path/to/vera/SKILL.md ~/.claude/skills/vera-language/SKILL.md ## Project status -Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 10,714 tests, 95% Python code coverage, 216 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. +Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 10,729 tests, 95% Python code coverage, 216 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. The reference compiler — parser, AST, type checker, contract verifier (Z3), WASM code generator, module system, browser runtime, and runtime contract insertion — is working. The language specification is in draft across [14 chapters](spec/). diff --git a/ROADMAP.md b/ROADMAP.md index a089e45c..f7a6945e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,7 +12,7 @@ Ordering derives from the design principles ([DESIGN.md](DESIGN.md)): verificati ## Where we are -10,714 tests, 216 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. +10,729 tests, 216 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. ## Stage 19 — The verification completeness sprint diff --git a/TESTING.md b/TESTING.md index 9415e754..7c2cc5a8 100644 --- a/TESTING.md +++ b/TESTING.md @@ -6,7 +6,7 @@ This is the single source of truth for Vera's testing infrastructure, coverage d | Metric | Value | |--------|-------| -| **Tests** | 10,714 across 164 files (~152,000 lines of test code; 10,546 passed + 26 stress, 142 skipped) | +| **Tests** | 10,729 across 164 files (~155,000 lines of test code; 10,561 passed + 26 stress, 142 skipped) | | **Compiler code coverage** | 95% Python, 87% JavaScript (CI minimum: 80%) | | **Conformance programs** | 216 programs across 9 spec chapters, validating every language feature | | **Example programs** | 42, all validated through `vera check` + `vera verify` | @@ -196,7 +196,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_tester_coverage.py` | 49 | 1,395 | Tester coverage gaps: String/Float64/ADT parameter input generation, Bool/Byte parameters, unsatisfiable preconditions, type expression edge cases, FP model-value extraction (NaN/Inf/signed-zero, #797), the #1208 naming pin (the threaded alias environment canonicalizes a type-argument alias), and the #1216 resolution set — an alias-typed parameter is trialed rather than skipped, its alias-spelled `requires` constrains the generated inputs, a refined alias reaches Z3 with its predicate so no trial violates codegen's entry guard, a `forall` variable shadowing a same-named module alias still resolves to an unsupported type variable, and a parameter that resolves to a non-encodable type still skips with that type named; plus the #1229 set — an UNTRANSLATABLE input constraint skips the function naming the blocking conjunct instead of scoring the resulting entry-guard trap as a contract failure, on the reported `string_length` repro, on a mixed clause (only the untranslatable conjunct is named), on a quantified precondition and on a refined parameter's predicate, with a Tier-3 control that must still be tested so a skip taxonomy firing on everything cannot pass | | `test_markdown.py` | 78 | 496 | Markdown parser: block/inline parsing, rendering, round-trips, edge cases | | `test_lsp.py` | 146 | 2470 | LSP transport + coordinate layer (#222 Phase C) and language features (#222 Phase D): parametrized code-point↔UTF-16 goldens incl. astral-plane fixtures and surrogate-pair snapping, Span (1-based, exclusive-end) and SourceLocation (0-based col) → LSP Range conversions, point→token-range widening, DocumentStore open/change/close + index invalidation, an in-process handler-drive test, and one stdio end-to-end round-trip against the real `vera lsp` subprocess (initialize → didOpen → shutdown → exit) pinning serverInfo + textDocumentSync capabilities; plus the Phase D feature suite — parse-error single-diagnostic path, type-error verification short-circuit, tier=3 in E520 diagnostic data, per-function tier Hint synthesis (and its suppression for functions with violated obligations), smallest-enclosing-span hover, De Bruijn slot goto (most-recent-parameter jump, out-of-range None, off-slot None, and the #1208 keying pins: a parameterised reference resolves, an alias-spelled parameter is reachable from a canonically-spelled reference, and a `forall` variable shadowing a module alias lands on the right parameter), and typed-hole completion (inside/after hole, away-from-hole None); plus the Phase E speculativeEdit suite — identical-text all-unchanged, breaking edit surfaces newly_undischarged (violated nat_sub) with canonical state untouched, strengthening edit surfaces newly_discharged, parse/type errors report ok:false, deleted functions report removed, proof_delta purity; plus the Phase F1 proposeEdit suite — the apply gate (clean and strengthening edits apply, breaking and non-compiling edits refuse), force overriding both gates with the delta still reported, wiring against a structural fake server (apply round-trip with exact full-document replacement range, refuse touches no canonical state, unopened-URI clamp sentinel), and full-document-range goldens (trailing-newline virtual line, UTF-16 end column); plus the Phase F2 strengthenContract suite — splice goldens (first-clause-only replacement with byte-identical remainder, ensures variant, unknown-fn None), the call-site audit pin (tightened precondition refused with newly_undischarged call_pre items, canonical state untouched), provable-ensures strengthening applies, and the three splice-target refusal paths (no analysis, unparseable document, unknown function); plus the Phase F3 addEffect suite — transitive-caller closure goldens (diamond in declaration order, leaf, unknown-fn None, recursion appears once), handler bounding (#725: a caller discharging the effect around its only call site drops out of the closure and is not rewritten, while a second unhandled path, a call in a handler clause, a handler for another effect, a handler naming a different instance of the same effect (`handle[State]` against a `State` propagation, which the checker does not discharge — end-to-end that caller must still be rewritten and the candidate must still apply, with the matching `State` propagation against the same fixture as the positive control that this handler key does prune something), and a bare `where`-helper call all keep it in, as does a refinement type argument at either depth (`Exn<{ @Int \| p }>` and `Exn>` render as their bare base type but discharge nothing of `Exn` — also pinned end-to-end), while an unparameterised `handle[IO]` does bound an `IO` propagation, a whitespace-spelled `State< Int >` request still bounds, and nesting bounds in either order (a matching handler inside a foreign one, a foreign one inside a matching one) — plus a key-level pin that `handle[Mod.IO]` keeps its module, and the effect-less query pinned handler-unaware; plus the two boundary pins the review added — a call in the handler's STATE INITIALISER keeps its edge, since the initialiser is evaluated in the enclosing scope before the handler is installed (pinned beside the E125 the checker raises there against a `pure` caller, with the identical call in the handler body clean as the contrast), and an alias-spelled handler does not bound a `State` propagation though the checker discharges it (`handle[State]` with `type MyAlias = Int` — the spelling comparison's under-prune, #1292, with the alias-spelled request as the control that does prune)), effect-row rewrite goldens (pure to singleton set, source-preserving append, already-present None, base-name identity blocking State next to State), diamond propagation applying one multi-site candidate with the bystander untouched, mixed append/replace rows with already-satisfied callers skipped, the fully-satisfied no-op shape, and the two refusal paths; plus the #728 instruction-contract suite — the LSP message carries description, rationale, and the Fix: paragraph (also pinning single E501 emission at the LSP surface), and a bare diagnostic maps to the description alone | -| `test_browser.py` | 274 | 4,356 | Browser parity: Python/wasmtime vs Node.js/JS-runtime output equivalence across IO, State, contracts, Markdown, Regex, and the examples the browser target can execute (two explicit lists in the file, not the whole `examples/` directory — interactive stdin, file IO, `DB` and the non-standalone `modules` example are excluded with their reasons recorded); plus the #349 `runtime.mjs` coverage battery — per-value-type and per-key-type Map variants, per-element-type Set variants, cold `Decimal` branches (exact-zero sign, negative-shift division, `decRoundPlaces` special cases, non-finite storage), `readJson`/`json_stringify` across every Json ADT tag, the Regex/Json `Result.Err` arms, and nested-Markdown walks. Two operations carry a canonical form the specification states rather than merely agreeing across the hosts, so their batteries assert more than equality: `json_stringify` (spec §9.7.1) pins the expected string on every Json ADT tag and on the number-rendering boundaries, checks three-pass idempotence, checks that a non-finite `JNumber` fails on both hosts *and* prints nothing, and checks the reference host's own number rendering differentially against a real `JSON.stringify` over doubles drawn from raw bit patterns; `md_render` (§9.7.3) pins the expected render, re-renders it to prove the fixed point, runs the round-trip property over a corpus carrying the container and multi-line shapes a flat corpus misses, and renders `MdBlock` values the test builds directly, since several renderer rules — a container's child separator, an empty container, a code span wider than one backtick — are unreachable through `md_parse`. `md_parse` itself is not yet at parity and its remaining divergence classes are a tracked bug ([#1301](https://github.com/aallan/vera/issues/1301)); the suite pins the shapes the two implementations agree on | +| `test_browser.py` | 289 | 4,478 | Browser parity: Python/wasmtime vs Node.js/JS-runtime output equivalence across IO, State, contracts, Markdown, Regex, and the examples the browser target can execute (two explicit lists in the file, not the whole `examples/` directory — interactive stdin, file IO, `DB` and the non-standalone `modules` example are excluded with their reasons recorded); plus the #349 `runtime.mjs` coverage battery — per-value-type and per-key-type Map variants, per-element-type Set variants, cold `Decimal` branches (exact-zero sign, negative-shift division, `decRoundPlaces` special cases, non-finite storage), `readJson`/`json_stringify` across every Json ADT tag, the Regex/Json `Result.Err` arms, and nested-Markdown walks. Two operations carry a canonical form the specification states rather than merely agreeing across the hosts, so their batteries assert more than equality: `json_stringify` (spec §9.7.1) pins the expected string on every Json ADT tag and on the number-rendering boundaries, checks three-pass idempotence, checks that a non-finite `JNumber` fails on both hosts *and* prints nothing, pins the object key orders an ordinary JS object cannot carry (array-index keys, which ES enumeration hoists to the front in ascending numeric order, and a `__proto__` key, whose assignment writes a prototype instead of a field) on both a parsed and a program-built object, and checks the reference host's own number rendering differentially against a real `JSON.stringify` over doubles drawn from raw bit patterns; `md_render` (§9.7.3) pins the expected render, re-renders it to prove the fixed point, runs the round-trip property over a corpus carrying the container and multi-line shapes a flat corpus misses, and renders `MdBlock` values the test builds directly, since several renderer rules — a container's child separator, an empty container, a code span wider than one backtick — are unreachable through `md_parse`. `md_parse` itself is not yet at parity and its remaining divergence classes are a tracked bug ([#1301](https://github.com/aallan/vera/issues/1301)); the suite pins the shapes the two implementations agree on | | `test_conformance.py` | 1080 | 125 | Parametrized conformance suite: parse, check, verify, run, format idempotency across 216 programs | | `test_prelude.py` | 29 | 585 | Prelude injection: Option/Result/array operation detection, combinator shadowing, type aliases, the reserved namespace every injected alias declaration lives in — checked against the checker's own E154 regex rather than a second spelling of the rule, since an alias the prelude injects outside it is one codegen resolves and the checker leaves opaque (#1184/#1221) — end-to-end compilation | | `test_checker_apply_fn.py` | 18 | 455 | #854 — `apply_fn` as a checker special form: zero-warning pins (API + CLI `--json` + closures.vera), E201 arity / E202 type / non-function-first-arg errors, E122/E125 effect-row enforcement for applied fn values, E151 redefinition rejection, variadic two-param application, prelude combinator regression pins | diff --git a/docs/llms-full.txt b/docs/llms-full.txt index fb6c9494..25dddfce 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -3256,7 +3256,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 10,714 tests, including a 216-program conformance suite +- 10,729 tests, including a 216-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/tests/test_browser.py b/tests/test_browser.py index d0f57312..24e842a4 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -3626,6 +3626,115 @@ def test_stringify_is_idempotent( assert _parity_stdout(src, tmp_path, f"jsonidem_{case_id}") == expected +# (id, JSON input as written in Vera source, expected canonical output). +# Every row is a *key* the canonical form must carry through the browser +# host's JS intermediates unchanged. None of them can be spelled with an +# alphabetically-ordered object, which is all the rest of the JSON +# battery uses — so none of them was covered. +_JSON_KEY_ORDER_CASES = [ + # Two array-index keys, written in descending order. + ("descending_index", '{\\"2\\":1,\\"1\\":2}', '{"2":1,"1":2}'), + # Numeric, not lexicographic: "10" before "9", with a non-index key + # after both so the two orderings cannot coincide. + ("numeric_vs_lexical", '{\\"10\\":1,\\"9\\":1,\\"a\\":1}', + '{"10":1,"9":1,"a":1}'), + # An index key inserted *after* a non-index one — the shape that + # moves to the front rather than merely swapping with a neighbour. + ("index_after_name", '{\\"b\\":1,\\"3\\":2,\\"a\\":3}', + '{"b":1,"3":2,"a":3}'), + ("nested_in_object", '{\\"x\\":{\\"2\\":1,\\"1\\":2}}', + '{"x":{"2":1,"1":2}}'), + ("nested_in_array", '[{\\"2\\":1,\\"1\\":2}]', '[{"2":1,"1":2}]'), + # ``__proto__`` is not an ordering case: assigning it to an ordinary + # JS object runs Object.prototype's setter and creates no own + # property at all, so the whole field vanishes from the output. + ("proto_key", '{\\"__proto__\\":{\\"a\\":1}}', '{"__proto__":{"a":1}}'), + # A duplicate key keeps the LAST value at the FIRST position, which + # is what a Python dict and a JS Map both do — pinned so the fix + # cannot quietly move the survivor to the end. + ("duplicate_key", '{\\"b\\":1,\\"a\\":1,\\"b\\":2}', '{"b":2,"a":1}'), +] + + +class TestBrowserJsonKeyOrderParity1293: + """Object key order survives the browser host's JS intermediates. + + Canonical (§9.7.1) key order is insertion order — what both hosts' + underlying ``Map`` bucket already holds, and what + ``dumps_canonical`` documents itself as preserving. The browser host + used to reach that bucket through *ordinary JS objects* on both sides + of the WASM boundary: ``JSON.parse`` returns one, ``writeJson`` + enumerated it with ``Object.entries``, and ``readJson`` rebuilt one + key by key. An ordinary object cannot carry insertion order — + ES OrdinaryOwnPropertyKeys lists array-index keys first, in ascending + numeric order — nor a key named ``__proto__``, whose assignment hits + ``Object.prototype``'s setter instead of creating an own property. + + Both losses are silent and neither is visible to an object with + alphabetically-ordered, non-numeric keys, which is the only shape + the rest of the JSON battery uses. So the hole sat inside exactly + the property #1293 claims to have fixed. + """ + + @pytest.mark.parametrize( + ("case_id", "json_text", "expected"), + _JSON_KEY_ORDER_CASES, + ids=[c[0] for c in _JSON_KEY_ORDER_CASES], + ) + def test_key_order_round_trip( + self, case_id: str, json_text: str, expected: str, tmp_path: Path, + ) -> None: + src = _json_round_trip_src(json_text) + assert _parity_stdout(src, tmp_path, f"jsonkey_{case_id}") == expected + + @pytest.mark.parametrize( + ("case_id", "json_text", "expected"), + _JSON_KEY_ORDER_CASES, + ids=[c[0] for c in _JSON_KEY_ORDER_CASES], + ) + def test_key_order_is_idempotent( + self, case_id: str, json_text: str, expected: str, tmp_path: Path, + ) -> None: + """Three passes, not one. + + A host that reorders on every pass and a host that reorders once + into a fixed point are both wrong, but only the first is caught + by a single round trip when the input happens to already be in + the host's preferred order. + """ + src = _json_round_trip_src(json_text, times=3) + assert ( + _parity_stdout(src, tmp_path, f"jsonkeyidem_{case_id}") == expected + ) + + def test_constructed_object_keeps_its_build_order( + self, tmp_path: Path, + ) -> None: + """A ``JObject`` the program *built* rather than parsed. + + A round trip cannot tell "both sides were fixed" from "neither + was": two compensating reorderings cancel, and the parse side's + ascending-index order happens to be a fixed point of the + stringify side's. This case has no parse side at all — the map + is built by ``map_insert`` in Vera, so the bucket order is the + program's, and only ``readJson`` plus the serialiser stand + between it and the output. + """ + src = """ +public fn main(@Unit -> @Unit) + requires(true) ensures(true) effects() +{ + let @Map = map_insert(map_insert(map_insert( + map_new(), "b", JNumber(1.0)), "3", JNumber(2.0)), "a", JNumber(3.0)); + IO.print(json_stringify(JObject(@Map.0))) +} +""" + assert ( + _parity_stdout(src, tmp_path, "jsonbuiltorder") + == '{"b":1,"3":2,"a":3}' + ) + + class TestBrowserJsonStringifyNonFinite1293: """A non-finite ``JNumber`` refuses to serialise on BOTH hosts (#1293). @@ -3729,9 +3838,22 @@ def test_random_doubles_match_json_stringify(self, tmp_path: Path) -> None: ]) expected = self._ecmascript_strings(values, tmp_path) + # A short Node reply would otherwise truncate the comparison + # silently: a bare ``zip`` stops at the shorter input, so a + # harness that returned the first ten strings and died would + # read as ten passes rather than one failure. Both the length + # assertion and ``strict=True`` are here because they catch it + # at different points — the assertion names the shortfall, and + # ``strict`` also guards a future refactor that builds the two + # lists separately. + assert len(expected) == len(values), ( + f"Node returned {len(expected)} strings for {len(values)} " + f"doubles; the differential would have compared only the " + f"common prefix" + ) mismatches = [ (v, format_json_number(v), want) - for v, want in zip(values, expected) + for v, want in zip(values, expected, strict=True) if format_json_number(v) != want ] assert not mismatches, ( diff --git a/vera/README.md b/vera/README.md index 1d542a41..fe8b2a12 100644 --- a/vera/README.md +++ b/vera/README.md @@ -752,7 +752,7 @@ The `ERROR_CODES` dict in `errors.py` maps every code to a short description (15 ## Test Suite -Testing spans a **pytest suite** of 10,714 tests across 164 files — compiler-internals unit tests plus a **conformance suite** (216 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. +Testing spans a **pytest suite** of 10,729 tests across 164 files — compiler-internals unit tests plus a **conformance suite** (216 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. See **[TESTING.md](../TESTING.md)** for the comprehensive testing reference -- test file table, conformance suite details, compiler code coverage, language feature coverage, helper conventions, validation scripts, CI pipeline, and guidelines for adding tests. diff --git a/vera/browser/runtime.mjs b/vera/browser/runtime.mjs index 89609319..09b38de3 100644 --- a/vera/browser/runtime.mjs +++ b/vera/browser/runtime.mjs @@ -2643,14 +2643,22 @@ function buildImportObject(module, moduleBytes) { if (typeof value === "object") { // JObject(Map) — tag=5, i32 wrapper ptr at offset 4 (#573) // + // #1293: the entry source is a ``Map`` for anything + // ``parseJsonOrdered`` built, and iterating one yields insertion + // order. ``Object.entries`` is kept for a plain object reaching + // here from somewhere else, but it is NOT order-preserving — + // array-index keys come out first, ascending — so nothing on the + // json_parse path may hand this branch one. + // // #708: each recursive ``writeJson(v)`` call returns a heap // ptr stored in the JS-side Map ``m`` only. Between // returning ep and ``m.set(k, ep)``, the result is in a JS // local — invisible to the conservative scan. Push each ep // before storing in m, then push wrapperPtr before the // final 8-byte alloc. + const entries = value instanceof Map ? value : Object.entries(value); const m = new Map(); - for (const [k, v] of Object.entries(value)) { + for (const [k, v] of entries) { const ep = writeJson(v); // PR #707 review: no matching pop here — unlike the JArray // branch above, ``m`` is a JS Map (not WASM memory), so @@ -2676,7 +2684,21 @@ function buildImportObject(module, moduleBytes) { return writeJson(String(value)); } - // Read a Json ADT from WASM memory back to a JS value. + // Read a Json ADT from WASM memory back to a JS value. A JObject + // decodes to a ``Map``, never to an ordinary object, because an + // ordinary object cannot carry two things the Json ADT does (#1293): + // + // * Key order. ES OrdinaryOwnPropertyKeys lists array-index keys + // first, in ascending numeric order, so ``{"2":1,"1":2}`` comes + // back out as ``{"1":2,"2":1}`` — and insertion order is what the + // canonical form of spec §9.7.1 is, matching the reference host, + // whose ``dict`` preserves it for free. + // * A key literally named ``__proto__``. ``obj["__proto__"] = v`` + // runs Object.prototype's setter and creates no own property at + // all, so the field disappears from the output entirely. + // + // Both are silent, so the Map is not a stylistic preference: it is + // the only JS shape that round-trips the ADT. function readJson(ptr) { const tag = readI32(ptr); if (tag === 0) return null; @@ -2695,11 +2717,14 @@ function buildImportObject(module, moduleBytes) { if (tag === 5) { // #706: the i32 at +4 is a Map wrapper whose bucket IS the map // (bucket-as-truth). Decode the Map directly; the - // values are i32 Json heap pointers. + // values are i32 Json heap pointers. ``decodeMap`` walks the + // bucket in slot order and already returns a JS ``Map``, so + // rebuilding the values in place is all that is needed — and + // keeps the bucket's order, which is the ADT's order. const wrapperPtr = readI32(ptr + 4); - const result = {}; + const result = new Map(); for (const [k, v] of decodeMap(wrapperPtr, 's', 'b')) { - result[String(k)] = readJson(Number(v)); + result.set(String(k), readJson(Number(v))); } return result; } @@ -2707,17 +2732,192 @@ function buildImportObject(module, moduleBytes) { return null; } + // Re-read JSON text into a tree whose objects are ``Map``s (#1293). + // + // ``JSON.parse`` cannot produce one: its objects are ordinary, so by + // the time any code sees the result the key order of spec §9.7.1 is + // already gone (array-index keys hoisted to the front, ascending) and + // a ``__proto__`` key has become a prototype write. The caller runs + // ``JSON.parse`` first and only reaches this scanner on text that + // parse ACCEPTED, which is what keeps the two implementations from + // disagreeing about what valid JSON is: the accept/reject decision + // and its Err message stay ECMAScript's, and so does every *leaf* + // value — each string and number is handed back to ``JSON.parse`` on + // its own slice rather than decoded a second way. This scanner only + // finds token boundaries and builds containers, so a throw from it is + // an internal bug, not bad input. + function parseJsonOrdered(text) { + let i = 0; + const fail = (what) => { + throw new Error( + `json_parse: ${what} at offset ${i}, in text JSON.parse accepted ` + + `— the order-preserving re-scan disagrees with JSON.parse` + ); + }; + const skipWs = () => { + while (i < text.length) { + const c = text.charCodeAt(i); + // RFC 8259 §2 whitespace: space, tab, LF, CR. + if (c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d) i++; + else break; + } + }; + const scanString = () => { + const start = i; + i++; // opening quote + for (;;) { + if (i >= text.length) fail("unterminated string"); + const c = text[i]; + if (c === "\\") { i += 2; continue; } + i++; + if (c === '"') break; + } + return JSON.parse(text.slice(start, i)); + }; + const scanNumber = () => { + const start = i; + while (i < text.length && "+-0123456789.eE".includes(text[i])) i++; + if (i === start) fail("expected a value"); + return JSON.parse(text.slice(start, i)); + }; + const scanValue = () => { + skipWs(); + if (i >= text.length) fail("unexpected end of input"); + const c = text[i]; + if (c === "{") { + i++; + // A Map, so the order below is the document's. A repeated key + // keeps its FIRST position and its LAST value, which is what a + // Python dict does on the reference host — and what JSON.parse + // does here, so the two agree on the duplicate case too. + const out = new Map(); + skipWs(); + if (text[i] === "}") { i++; return out; } + for (;;) { + skipWs(); + if (text[i] !== '"') fail("expected a key"); + const k = scanString(); + skipWs(); + if (text[i] !== ":") fail("expected ':'"); + i++; + out.set(k, scanValue()); + skipWs(); + if (text[i] === ",") { i++; continue; } + if (text[i] === "}") { i++; return out; } + fail("expected ',' or '}'"); + } + } + if (c === "[") { + i++; + const out = []; + skipWs(); + if (text[i] === "]") { i++; return out; } + for (;;) { + out.push(scanValue()); + skipWs(); + if (text[i] === ",") { i++; continue; } + if (text[i] === "]") { i++; return out; } + fail("expected ',' or ']'"); + } + } + if (c === '"') return scanString(); + if (text.startsWith("true", i)) { i += 4; return true; } + if (text.startsWith("false", i)) { i += 5; return false; } + if (text.startsWith("null", i)) { i += 4; return null; } + return scanNumber(); + }; + const value = scanValue(); + skipWs(); + if (i !== text.length) fail("trailing text"); + return value; + } + + // Serialize a value ``readJson`` produced into canonical JSON text + // (spec §9.7.1) — the JS twin of ``dumps_canonical`` in + // ``vera/wasm/json_serde.py``, kept structurally parallel to it. + // + // ``JSON.stringify`` cannot do this job any more: it does not know + // about ``Map``, and its ordinary-object enumeration is where the key + // order was being lost. Leaf rendering is still ECMAScript's — + // strings through ``JSON.stringify``, finite numbers through + // ``String``, which IS the Number::toString that JSON.stringify would + // have used — so the canonical form is unchanged for every value that + // was already coming out right. + // + // Anything outside ``readJson``'s range raises rather than being + // coerced, matching ``dumps_canonical``'s TypeError: a value that is + // not a Json value means the ADT walk went wrong, and a + // plausible-looking string would hide it. + function stringifyCanonical(value) { + const parts = []; + const emit = (node) => { + if (node === null) { parts.push("null"); return; } + if (node === true) { parts.push("true"); return; } + if (node === false) { parts.push("false"); return; } + if (typeof node === "number") { + // RFC 8259 has no NaN and no Infinity, so there is no right + // value to return for one — only a right way to fail. Bare + // JSON.stringify writes "null", swapping a value the format + // cannot carry for a different, perfectly valid one that no + // later consumer can distinguish from a genuine null. The + // reference runtime has always refused; this refuses with the + // same sentence (#1293). + if (!Number.isFinite(node)) { + throw new Error( + `json_stringify: ${String(node)} is not representable in JSON ` + + `— RFC 8259 has no NaN or Infinity. Guard with float_is_nan ` + + `/ float_is_infinite before serialising.` + ); + } + parts.push(String(node)); + return; + } + if (typeof node === "string") { parts.push(JSON.stringify(node)); return; } + if (Array.isArray(node)) { + parts.push("["); + node.forEach((item, idx) => { + if (idx) parts.push(","); + emit(item); + }); + parts.push("]"); + return; + } + if (node instanceof Map) { + parts.push("{"); + let first = true; + for (const [k, v] of node) { + if (!first) parts.push(","); + first = false; + parts.push(JSON.stringify(String(k))); + parts.push(":"); + emit(v); + } + parts.push("}"); + return; + } + throw new Error( + `json_stringify: readJson produced ${typeof node}, which is not a ` + + `Json value; the ADT walk is wrong` + ); + }; + emit(value); + return parts.join(""); + } + if (needed.has("json_parse")) { imports.vera.json_parse = (ptr, len) => { const text = readString(ptr, len); // Same failure-domain split as hostMdParse: only JSON.parse // errors become Err(String); gcGuard-walk failures trap loudly. - let parsed; try { - parsed = JSON.parse(text); + JSON.parse(text); } catch (e) { return allocResultErrString(e.message || String(e)); } + // #1293: JSON.parse decided whether the text is JSON; its result + // is discarded because it cannot carry key order. The tree the + // ADT is built from comes from the order-preserving re-scan. + const parsed = parseJsonOrdered(text); // #708 (PR #707): wrap in gcGuard and push jsonPtr // before allocResultOkI32's alloc can fire GC. writeJson // has its own internal guard that pops on return — by the @@ -2732,41 +2932,12 @@ function buildImportObject(module, moduleBytes) { if (needed.has("json_stringify")) { imports.vera.json_stringify = (ptr) => { - const value = readJson(ptr); - // #1293: RFC 8259 has no NaN and no Infinity, so there is no - // right value to return for one — only a right way to fail. - // Bare JSON.stringify writes "null", swapping a value the format - // cannot carry for a different, perfectly valid one that no later - // consumer can distinguish from a genuine null. The reference - // runtime has always refused; the replacer makes this host refuse - // with the same sentence. The compact form JSON.stringify - // already emits IS the canonical form (spec §9.7.1), so nothing - // else about the call changes. - const text = JSON.stringify(value, (_key, v) => { - if (typeof v === "number" && !Number.isFinite(v)) { - throw new Error( - `json_stringify: ${String(v)} is not representable in JSON — ` + - `RFC 8259 has no NaN or Infinity. Guard with float_is_nan / ` + - `float_is_infinite before serialising.` - ); - } - return v; - }); - // JSON.stringify returns undefined for values with no JSON form - // (bare undefined, symbols, functions). readJson cannot produce - // one, so this is unreachable — but it used to fall back to - // "null", which is the same silent substitution #1293 removed one - // layer up, and an unreachable branch is exactly where a silent - // wrong answer survives unnoticed. Raise instead, matching - // dumps_canonical's TypeError for a value outside read_json's - // domain: whatever produced it, the ADT walk is wrong. - if (text === undefined) { - throw new Error( - "json_stringify: readJson produced a value with no JSON " + - "representation; the ADT walk is wrong" - ); - } - return allocString(text); + // #1293: the canonical form of spec §9.7.1, emitted by a walk + // that mirrors the reference host's ``dumps_canonical`` — compact + // separators, insertion-ordered keys, ECMAScript number + // rendering, and a refusal on a non-finite number rather than the + // silent ``null`` bare JSON.stringify would substitute. + return allocString(stringifyCanonical(readJson(ptr))); }; } From c6f25cf8799b6c128c7d6cc59e8baed5f485e073 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 02:48:12 +0100 Subject: [PATCH 11/69] Hold the parity and Markdown claims to what was measured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five documentation corrections from the review, each checked against a measurement taken at this commit rather than reasoned from the text. §9.7.3 stated the round-trip property md_parse(md_render(b)) == Ok(b) without qualification, two paragraphs above the three code-span shapes that break it. The exceptions paragraph scopes the fixed-point sentence it follows, not the property sentence at the top, so a reader taking that one at face value gets a property that does not hold. Scoped in place. The same rule's `> ` code span carried a trailing space the rendered page cannot show, which is the whole contrast with the bare `>` beside it; written out as words instead. §12.9.3 re-asserted "identical results across the shared surface" at the end of the section, after the paragraph recording md_parse as the one operation on that surface where the requirement is not met — the two sentences contradict each other. Qualified, along with the opening sentence's forward pointer, and the md_render fixed-point claim, which §9.7.3 now scopes to the shapes the subset can write. README.md's equivalent sentence already carries its carve-out two sentences later and is left alone. The FAQ named two of #1301's nine divergence classes and read as though that were the inventory; it now gives the count with the largest class as the example. KNOWN_ISSUES.md's real-document denominator was re-measured with the review's own instrument at this commit: 329 of 4,853 blank-line sections (the corpus is the repo's own Markdown, so editing the docs moves it), and 211 of 1,471 adversarial inputs, unchanged. TESTING.md's headline said ~152,000 lines of test code where its own 162 per-file rows sum to 154,010, and the test_codegen_json.py row did not mention the canonical-serialization battery that file gained. Skip-changelog: documentation wording and counts only, of behaviour the [Unreleased] bullets already describe. Co-Authored-By: Claude --- FAQ.md | 2 +- KNOWN_ISSUES.md | 2 +- TESTING.md | 2 +- docs/llms-full.txt | 2 +- spec/09-standard-library.md | 4 ++-- spec/12-runtime.md | 6 +++--- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/FAQ.md b/FAQ.md index 93a8a62b..4c073154 100644 --- a/FAQ.md +++ b/FAQ.md @@ -170,7 +170,7 @@ vera compile --target browser examples/hello_world.vera # index.html ``` -Serve it with any HTTP server and open `index.html` — no build step, no bundler, no dependencies. The JavaScript runtime provides browser-appropriate implementations of all Vera host bindings: `IO.print` writes to the page, `IO.read_line` uses `prompt()`, and all other operations (State, contracts, JSON, Markdown rendering) work identically to the wasmtime runtime. `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states — §9.7.1 and §9.7.3 — rather than by the two hosts happening to agree, which is what the parity suite checks them against. `md_parse` is the exception: the two hand-written parsers still disagree on how a paragraph's plain-text runs are grouped — invisible to `md_render`, since they concatenate to the same text — and on a few block markers the §9.7.3 subset leaves open, such as a `+` bullet or a list nested more than two deep. That is a tracked bug ([#1301](https://github.com/aallan/vera/issues/1301)). +Serve it with any HTTP server and open `index.html` — no build step, no bundler, no dependencies. The JavaScript runtime provides browser-appropriate implementations of all Vera host bindings: `IO.print` writes to the page, `IO.read_line` uses `prompt()`, and all other operations (State, contracts, JSON, Markdown rendering) work identically to the wasmtime runtime. `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states — §9.7.1 and §9.7.3 — rather than by the two hosts happening to agree, which is what the parity suite checks them against. `md_parse` is the exception: the two hand-written parsers disagree across nine measured classes of input the §9.7.3 subset leaves open, the largest by a wide margin being how a paragraph's plain-text runs are grouped — invisible to `md_render`, since the runs concatenate to the same text — and the rest render-visible, from how emphasis markers are scanned to block markers such as a `+` bullet or a list nested more than two deep. That is a tracked bug ([#1301](https://github.com/aallan/vera/issues/1301)). Two effects are refused outright rather than merely differing. `Inference` and `DB` return an explanatory `Err` from every operation in the browser, because the API key or database credential they would need is readable from page source and network traffic in client-side JavaScript. Reach them through a server-side endpoint and call it with `Http`, which does run in the browser — it is backed by `XMLHttpRequest`, not a stub. That refusal is a deliberate platform boundary; spec §9.5.5 states it for `Inference`. diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index b92bc6a6..a2625615 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -9,7 +9,7 @@ Defects in shipped compiler, runtime, or tooling behaviour — this table matche | Bug | Issue | |-----|-------| | A non-finite `JNumber` reaching `json_stringify` fails with the right sentence and the wrong presentation: `vera run` emits a **61-line, 3,214-character Python traceback** — 15 stack frames, 7 of them inside `.venv/`, and **no mention of the user's `.vera` file at all** — with the actual message (`json_stringify: NaN is not representable in JSON — RFC 8259 has no NaN or Infinity. …`) as its last line. A contract violation in the same program shape produces **5 lines**: the failed clause and a source backtrace with `.vera` line numbers. The buffered stdout is discarded on the way out rather than surfaced, so the `#522` output-preservation contract is lost too. The invariant this breaks is already written down, in a comment on `host_print` in `vera/codegen/api.py`: "A user-level program must never produce a Python traceback regardless of what it does — the `WasmTrapError` contract from #516/#522/#547 holds here too." Root cause: `execute()` converts an exception into `WasmTrapError` only when `type(exc).__name__` is `Trap` or `WasmtimeError`, and a `ValueError` raised inside a host callback arrives at that handler as a `ValueError`, so the branch is skipped — no classification, no source-map frame resolution, no captured output. `json_stringify` is the case that surfaced it ([#1293](https://github.com/aallan/vera/issues/1293)), but the gap is generic: any host callback raising a non-`Trap` Python exception takes the same path. Fix direction: give host-callback failures a Vera-native shape — a dedicated error type classified alongside the traps, or widen the conversion to any exception escaping a host import — carrying the captured stdout and a source-mapped backtrace either way. | [#1302](https://github.com/aallan/vera/issues/1302) | -| `md_parse` diverges between the native and browser runtimes on **211 of 1,471** adversarial inputs (14.3%) and **329 of 4,850** blank-line-separated sections of the project's own documentation (6.8%), measured by comparing the two ADTs directly rather than their renders. Nine classes, each with a one-line repro. The largest by a wide margin — 173 of the 211 — is *plain-text run grouping*: the browser emits one `MdText` per scan segment where the reference coalesces adjacent runs, so `**unclosed` is `[MdEmph([]), MdText("unclosed")]` natively and `[MdText("*"), MdText("*unclosed")]` in the browser. That class is invisible to `md_render` (the runs concatenate to the same text) but not to a Vera program that matches on the ADT, which is what makes it a §12.9.3 violation rather than a cosmetic one. The rest are render-visible: emphasis/strong scanning (`***both***` renders `**both****` natively, `***both***` in the browser); list-continuation indent width, where the reference strips exactly two (or three) characters and the browser strips all leading whitespace (`- a\n b` → `- a b` vs `- a b`); a `+` bullet, unrecognised in the browser; an `n)` ordered marker, likewise; a loose list, one list natively and two in the browser (`- a\n\n- b`); nesting past two levels, flattened in the browser (`- a\n - b\n - c`); a thematic break with internal spaces (`* * *`); and a table without a separator row (`\| a \| b \|\n- li`). Neither implementation is the specification — §9.7.3 pins the ADT, not the grammar that produces it — so closing this means choosing a parse for each class and stating it, then mirroring. Successor to [#1294](https://github.com/aallan/vera/issues/1294), which closed the `md_render` half; the parity suite pins every shape the two implementations do agree on, so the set cannot widen unnoticed. | [#1301](https://github.com/aallan/vera/issues/1301) | +| `md_parse` diverges between the native and browser runtimes on **211 of 1,471** adversarial inputs (14.3%) and **329 of 4,853** blank-line-separated sections of the project's own documentation (6.8%), measured by comparing the two ADTs directly rather than their renders. Nine classes, each with a one-line repro. The largest by a wide margin — 173 of the 211 — is *plain-text run grouping*: the browser emits one `MdText` per scan segment where the reference coalesces adjacent runs, so `**unclosed` is `[MdEmph([]), MdText("unclosed")]` natively and `[MdText("*"), MdText("*unclosed")]` in the browser. That class is invisible to `md_render` (the runs concatenate to the same text) but not to a Vera program that matches on the ADT, which is what makes it a §12.9.3 violation rather than a cosmetic one. The rest are render-visible: emphasis/strong scanning (`***both***` renders `**both****` natively, `***both***` in the browser); list-continuation indent width, where the reference strips exactly two (or three) characters and the browser strips all leading whitespace (`- a\n b` → `- a b` vs `- a b`); a `+` bullet, unrecognised in the browser; an `n)` ordered marker, likewise; a loose list, one list natively and two in the browser (`- a\n\n- b`); nesting past two levels, flattened in the browser (`- a\n - b\n - c`); a thematic break with internal spaces (`* * *`); and a table without a separator row (`\| a \| b \|\n- li`). Neither implementation is the specification — §9.7.3 pins the ADT, not the grammar that produces it — so closing this means choosing a parse for each class and stating it, then mirroring. Successor to [#1294](https://github.com/aallan/vera/issues/1294), which closed the `md_render` half; the parity suite pins every shape the two implementations do agree on, so the set cannot widen unnoticed. | [#1301](https://github.com/aallan/vera/issues/1301) | | Codegen's `_known_fns` mirrors the flat `_fn_sigs`, which keys a name the call site **cannot see** under its bare name, so the #1284 ownership predicate answers "user-owned" at a site where the checker resolved the effect operation — one rule, two tables that disagree. Three demonstrated routes: a `private fn get` in an imported module (still reachable, so still emitted); a `public fn get` a selective import excludes; and a `where` helper of a **`forall` parent**, which keeps a bare `_fn_sigs` key beside its clone-qualified one (`helper`/`id` in `ch09_generic_where_helper.vera`) where a non-generic parent's helper does not. All three are check-green, and how the failure lands depends on the route and on the widths: the two import routes are the dangerous ones — where both cells are i64 the module loads and returns the invisible helper's value instead of the cell's (**silent wrong value**), and where the widths differ it fails to load (`type mismatch: expected i64, found i32`). The generic-`where` route is always **loud**: the bare key exists in the signature table while no bare symbol is emitted (the helper is only ever `holder$Bool$where$get`), so the call fails at WAT assembly with `unknown func: failed to find name $get` and no E-code. The checker's answer is nailed by a type oracle rather than asserted — the invisible `get` returns `@Bool` while the caller returns `@Int` from `get(())` and checks green, which is only possible if the checker typed the call from the `State` cell — and each route carries a rename control that returns the checker's value. Pre-existing, not introduced by the #1284 fix, which made the rule one function without making either table scope-accurate. Fix direction: `_known_fns` becomes the names visible in the compiling declaration's **lexical** scope — module scope is not enough, since a generic's `where` helper *is* in the module and still is not in a sibling's scope — or the flattening stops keying invisible names bare, the `mod$…` renaming `_resolve_module_call_wasm_name` already applies to shadowed generics being the precedent. Same module-namespace table family as [#1281](https://github.com/aallan/vera/issues/1281). Spec §7.4 states the language rule ("declarations first — a property of the call site's scope alone"); this row tracks codegen's violation of it. | [#1299](https://github.com/aallan/vera/issues/1299) | | A postcondition may name a `State` the function's effect row never declares, and `vera check` accepts it: `ensures(new(State) == false)` under `effects(>)` reports OK, then `vera compile` fails with **E699** — the internal-compiler-error diagnostic whose own text says the type checker should have rejected the input, which is exactly the situation. Both forms land there for the same reason (no cell of that family exists, so `old()` finds no snapshot local and, since [#1285](https://github.com/aallan/vera/issues/1285), `new()` finds no getter). Loud and never a wrong answer, so this is diagnostic quality rather than soundness — but it is a check-green program that cannot compile, reported against the compiler instead of against the program, with a bug-report request the user should not act on. Before #1285 the `new()` side was worse than loud: the name-keyed lookup found the row's other getter and silently read the wrong cell. Fix direction: validate an `OldExpr`/`NewExpr`'s effect reference against the declared row where the checker already validates the rest of the clause, one rule for both forms; `test_a_family_the_row_does_not_declare_is_loud_on_both_sides` pins today's E699 and is the test to flip. | [#1298](https://github.com/aallan/vera/issues/1298) | | Spec §1.4 says its reserved keywords MUST NOT be used as function names, and for **seventeen of them nothing enforces it**: `then`, `else`, `data`, `type`, `module`, `import`, `public`, `private`, `requires`, `ensures`, `invariant`, `decreases`, `effect`, `with`, `in`, `where` and `pure` all declare and pass `vera check` as `public fn (@Int -> @Int)`. (`handle` is an eighteenth acceptance and is the sanctioned host-entry-point carve-out, §5.2; the other eleven names — `resume`, `exists`, `forall`, `match`, `assert`, `assume`, `fn`, `let`, `if`, `true`, `false` — are refused **E153**.) They are not even traps: `private fn with(@Int -> @Int)` and its `where` / `type` / `pure` / `requires` / `import` siblings are **callable** from the same file, so the declaration resolves and runs like any other function. The comment above `_KEYWORD_FN_NAMES` in `vera/checker/registration.py` gives the opposite as the reason those names are absent from the set — that the contextual lexer "does not admit them as a function name, so no declaration reaches this checker at all" — so the omission rests on a premise the tree refutes, and neither the parser nor the checker holds the MUST. Two resolutions, and the choice is a ruling rather than a mechanical fix: extend `_KEYWORD_FN_NAMES` so the seventeen join `resume` under E153, or narrow §1.4's MUST to the names actually reserved. The `resume` precedent leans to enforcing — `resume` is likewise not a keyword token anywhere and likewise parses, and was reserved *at the checker* precisely because the parser had no keyword to refuse it with — and E153's own rationale, that a keyword names one construct and nothing else (DESIGN principle 3), does not depend on the name being a trap. Until the ruling lands, §1.4's `handle` carve-out sentence deliberately makes no enforcement claim, so the spec does not assert a MUST it cannot back. | [#1296](https://github.com/aallan/vera/issues/1296) | diff --git a/TESTING.md b/TESTING.md index 7c2cc5a8..cc8e0ad3 100644 --- a/TESTING.md +++ b/TESTING.md @@ -155,7 +155,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_codegen_numeric.py` | 86 | 1,104 | Math builtins (#199), numeric type conversions (#208), Float64 predicates + constants (#212), int64-min / float-carry to-string regressions (#475) (#419 split) | | `test_codegen_io.py` | 42 | 821 | IO operations (#135: read\_line, read\_file, write\_file, args, exit, get\_env, sleep, time, stderr), Markdown + Regex host bindings (#419 split) | | `test_codegen_collections.py` | 69 | 1,141 | Map + Set collections (#62), wrapper-handle bit-31 tagging (#578) (#419 split) | -| `test_codegen_json.py` | 116 | 1,112 | Json collection, typed accessors (#419 split) | +| `test_codegen_json.py` | 116 | 1,112 | Json collection, typed accessors (#419 split), canonical serialization (#1293 — `format_json_number`'s ECMAScript boundaries and `dumps_canonical`'s shape, insertion-ordered keys, non-finite refusal, and rejection of values outside `read_json`'s domain) | | `test_codegen_decimal.py` | 57 | 779 | Decimal collection, Decimal monomorphization (#419 split) | | `test_codegen_host_effects.py` | 71 | 1,123 | Html/Http/Inference host effects, provider dispatch, postcondition host-import propagation (#823) (#419 split) | | `test_codegen_nat_guards.py` | 61 | 1,435 | **`@Nat` runtime guards**: subtraction underflow (#520) and binding-site narrowing (#552 let site; #747 tuple-destructure / match-bind / ADT sub-pattern / ctor-field / call-arg sites; #758 per-leaf function-return guards incl. type-alias returns and TCO preservation on mixed-arm tails — `i64.lt_s; unreachable` net, `@Int` targets exempt) (#419 split); #758 `@Int -> @Nat` return-position guard; #983 review adds alias-aware return gates (`type Count = Nat` narrow, `type MyInt = Int` widen), the alias-to-refinement single-guard exclusion, and the per-narrowing-leaf emission that keeps a non-narrowing `@Nat -> @Nat` recursive tail call's `return_call` (TCO) intact. #1256 extends both alias-aware gates to a parameterised alias APPLICATION (`type Ident = T; type Count = Ident;`), which the name-only chase resolved to the bare head `Ident` — so neither gate fired and `f(0 - 5)` returned -5 through the `@Nat` slot, #983's silent negative one spelling over. Each parameterised case carries its unparameterised twin as the oracle (the claim is that the two spellings compile to the SAME guard, which a bare presence assertion would not catch losing), plus the run-trap on the violating value, the pass on a satisfying one, and the refinement-over-application control that pins the `_refinement_guard_parts` conjunct still keeps a refined return single-guarded | diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 25dddfce..d0338e79 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -3147,7 +3147,7 @@ vera compile --target browser examples/hello_world.vera # index.html ``` -Serve it with any HTTP server and open `index.html` — no build step, no bundler, no dependencies. The JavaScript runtime provides browser-appropriate implementations of all Vera host bindings: `IO.print` writes to the page, `IO.read_line` uses `prompt()`, and all other operations (State, contracts, JSON, Markdown rendering) work identically to the wasmtime runtime. `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states — §9.7.1 and §9.7.3 — rather than by the two hosts happening to agree, which is what the parity suite checks them against. `md_parse` is the exception: the two hand-written parsers still disagree on how a paragraph's plain-text runs are grouped — invisible to `md_render`, since they concatenate to the same text — and on a few block markers the §9.7.3 subset leaves open, such as a `+` bullet or a list nested more than two deep. That is a tracked bug ([#1301](https://github.com/aallan/vera/issues/1301)). +Serve it with any HTTP server and open `index.html` — no build step, no bundler, no dependencies. The JavaScript runtime provides browser-appropriate implementations of all Vera host bindings: `IO.print` writes to the page, `IO.read_line` uses `prompt()`, and all other operations (State, contracts, JSON, Markdown rendering) work identically to the wasmtime runtime. `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states — §9.7.1 and §9.7.3 — rather than by the two hosts happening to agree, which is what the parity suite checks them against. `md_parse` is the exception: the two hand-written parsers disagree across nine measured classes of input the §9.7.3 subset leaves open, the largest by a wide margin being how a paragraph's plain-text runs are grouped — invisible to `md_render`, since the runs concatenate to the same text — and the rest render-visible, from how emphasis markers are scanned to block markers such as a `+` bullet or a list nested more than two deep. That is a tracked bug ([#1301](https://github.com/aallan/vera/issues/1301)). Two effects are refused outright rather than merely differing. `Inference` and `DB` return an explanatory `Err` from every operation in the browser, because the API key or database credential they would need is readable from page source and network traffic in client-side JavaScript. Reach them through a server-side endpoint and call it with `Http`, which does run in the browser — it is backed by `XMLHttpRequest`, not a stub. That refusal is a deliberate platform boundary; spec §9.5.5 states it for `Inference`. diff --git a/spec/09-standard-library.md b/spec/09-standard-library.md index b4bd2deb..a3b332fc 100644 --- a/spec/09-standard-library.md +++ b/spec/09-standard-library.md @@ -2267,12 +2267,12 @@ public fn md_render(@MdBlock -> @String) effects(pure) ``` -Renders an `MdBlock` to a canonical Markdown string. Always succeeds. The round-trip property `md_parse(md_render(b)) == Ok(b)` should hold: rendering then re-parsing preserves structure. Every runtime produces the same string (§12.9.3). +Renders an `MdBlock` to a canonical Markdown string. Always succeeds. For every block the subset can write back — all but the three code-span shapes named below — the round-trip property `md_parse(md_render(b)) == Ok(b)` should hold: rendering then re-parsing preserves structure. Every runtime produces the same string (§12.9.3). Four rules carry that property. The first two follow from the ADT having no line-break node (see the design note above); the last two from a container needing to be readable back as one block: - **A paragraph is one line.** Its inline content is rendered without internal newlines. A parser collapses a paragraph's soft line breaks to spaces on the way in, so nothing survives to be re-emitted. -- **A container prefixes every line of every child.** A block quote writes `> ` before each rendered line and a bare `>` for a blank one; a list item writes its marker before the first line and an equal-width indent before the rest. Prefixing only a child's first line would leave a fenced block's body — or a nested block's continuation — outside its container, and the next `md_parse` would read it as a sibling. +- **A container prefixes every line of every child.** A block quote writes `>` and a space before each rendered line, and a bare `>` for a blank one; a list item writes its marker before the first line and an equal-width indent before the rest. Prefixing only a child's first line would leave a fenced block's body — or a nested block's continuation — outside its container, and the next `md_parse` would read it as a sibling. - **A container separates its children.** A block quote writes a bare `>` between adjacent children, as a document writes a blank line between its own. Without it two quoted paragraphs render as two adjacent quoted lines, which re-parse as one paragraph. An empty container still occupies its line: a `MdBlockQuote` with no children renders `>`, because rendering it as nothing deletes the block and leaves the enclosing separator dangling. - **A code span is fenced longer than its content.** The fence is one backtick more than the longest backtick run inside the span, with a single padding space on each side when the content itself starts or ends with a backtick. A fixed-width fence terminates on a run inside the content. diff --git a/spec/12-runtime.md b/spec/12-runtime.md index 0cf505a1..ce820b56 100644 --- a/spec/12-runtime.md +++ b/spec/12-runtime.md @@ -658,13 +658,13 @@ The rows above fall into three kinds, and the distinction matters when reading a - **Deliberate boundaries.** `IO.read_file` / `IO.write_file` (no filesystem), `` (no accept loop), and `Inference.complete` / `DB.query` / `DB.execute` (the credential would be readable from page source and network traffic) return `Err` on every call **by definition of the browser target**, not pending a fix. Reach a filesystem, a database or a model provider through a server-side endpoint and call it with `Http`, which does run in the browser. - **Not yet implemented.** `IO.read_char` is the only row of this kind: the browser `Err` is a stub awaiting JSPI suspend/resume ([#609](https://github.com/aallan/vera/issues/609)), so unlike a boundary it is expected to become an `Ok` one day. -Across the surface the two runtimes actually share — State, contracts, JSON, `md_render` and the rest of the non-IO operations — results are identical. The boundary rows are outside that claim by construction, having no browser counterpart to agree with, and an `Http` call's outcome is host-specific for a milder reason: the browser issues it through synchronous `XMLHttpRequest`, so a JavaScript host without one returns an explanatory `Err` where the reference runtime performs the request. +Across the surface the two runtimes actually share — State, contracts, JSON, `md_render` and the rest of the non-IO operations — results are identical, with the single exception `md_parse` records below. The boundary rows are outside that claim by construction, having no browser counterpart to agree with, and an `Http` call's outcome is host-specific for a milder reason: the browser issues it through synchronous `XMLHttpRequest`, so a JavaScript host without one returns an explanatory `Err` where the reference runtime performs the request. -Two operations reach that identity by carrying a canonical form the specification states rather than by both hosts happening to agree. `json_stringify` emits the one form §9.7.1 pins, down to separators and number rendering, and refuses a non-finite number on both hosts instead of one refusing and the other substituting `null`. `md_render` emits the canonical Markdown §9.7.3 describes — soft line breaks collapsed, each container's prefix re-applied to every line of every child, children separated, code spans fenced wider than their content — which is what makes the render a fixed point on both hosts. +Two operations reach that identity by carrying a canonical form the specification states rather than by both hosts happening to agree. `json_stringify` emits the one form §9.7.1 pins, down to separators and number rendering, and refuses a non-finite number on both hosts instead of one refusing and the other substituting `null`. `md_render` emits the canonical Markdown §9.7.3 describes — soft line breaks collapsed, each container's prefix re-applied to every line of every child, children separated, code spans fenced wider than their content — which is what makes the render a fixed point on both hosts, outside the three code-span shapes §9.7.3 records as unwriteable in the subset, which both hosts lose identically rather than differently. `md_parse` is the one operation on the shared surface where the requirement is not yet met. The two implementations are hand-written parsers for the §9.7.3 subset, and they still disagree on inputs the subset does not pin. One class is invisible to `md_render`: how adjacent plain-text runs are grouped inside a paragraph — the largest by count, and hidden at the render level because the runs concatenate to the same text. The rest are visible in the rendered output. Two are inline: how emphasis and strong markers are scanned when they nest or go unclosed, and how much indentation a continuation line loses inside a list item (the reference strips a fixed two or three characters, the browser all of it). The others are block markers — a `+` bullet, an `n)` ordered marker, a list item separated from the next by a blank line, a list nested more than two deep, a thematic break written with internal spaces, and a table without a separator row. The divergence is a tracked bug ([#1301](https://github.com/aallan/vera/issues/1301)), not part of the browser target's definition; the parity suite covers the shapes they do agree on, so a regression on one of those goes red. -Fused async preserves the underlying value whenever the two hosts' results are comparable. Only the evaluation strategy differs, which is spec-conformant — §9.5.4 says an implementation MAY evaluate `async(e)` concurrently — and any difference that remains comes from the `Http` outcome underneath rather than from `async` itself. Mandatory parity tests enforce identical results across the shared surface. +Fused async preserves the underlying value whenever the two hosts' results are comparable. Only the evaluation strategy differs, which is spec-conformant — §9.5.4 says an implementation MAY evaluate `async(e)` concurrently — and any difference that remains comes from the `Http` outcome underneath rather than from `async` itself. Mandatory parity tests enforce identical results across the shared surface, `md_parse`'s open divergence classes aside. ### 12.9.4 Memory Protocol From 4bb8ab6727f73cac906782c37a9a0e6e31b98d3c Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 03:22:22 +0100 Subject: [PATCH 12/69] Table the two bugs this branch's review turned up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both were found while probing the JSON host bindings and neither is this branch's to fix, so they join the Bugs table against their own trackers rather than riding the parity work. #1305: json_keys' result cannot be used from compiled Vera. The Some arm of its Option> binds a match binder whose local codegen emits as (local $lN i32_pair) — the internal String (ptr, len) spelling, not a WASM value type — so a ten-line program that passes vera check fails WAT assembly. Narrow, and the two controls say why: array_length(map_keys(...)) compiles, so Array alone is fine, and json_get_array's Some(@Array) binder compiles, so a binder alone is fine. #1306: json_parse's accept domain differs between the hosts. The reference host's json.loads admits NaN, Infinity and -Infinity by default where the browser's JSON.parse refuses them, so the same module bytes take the Ok arm natively and the Err arm in the browser — and natively the value then reaches json_stringify, whose #1293 refusal #1302 turns into a traceback. No test covered it because every JSON input in the parity battery is RFC-conformant. The #1301 row is re-measured at this tree, which is what the rebase onto the release tip made necessary: the corpus is the repository's own Markdown, and group A's documentation edits added four sections to it, so the denominator moved from 4,853 to 4,857 while the divergent count held at 329. The row now carries the revision it was measured at and says the denominator moves with the documentation, so the next drift reads as drift rather than as a stale number. Its closing claim that the parity suite pins "every shape" the two implementations agree on is dropped in the same edit: the measurement in this branch refutes it — a corpus of a few dozen entries against 1,260 agreeing shapes — and spec §12.9.3 was already corrected for exactly that overclaim. Co-Authored-By: Claude --- KNOWN_ISSUES.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index a2625615..1cd87786 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -8,8 +8,10 @@ Defects in shipped compiler, runtime, or tooling behaviour — this table matche | Bug | Issue | |-----|-------| +| `json_parse` accepts `NaN`, `Infinity` and `-Infinity` on the reference runtime and refuses them in the browser. The reference host calls `json.loads`, whose default `parse_constant` admits all three; the browser host calls `JSON.parse`, which refuses them as RFC 8259 requires. Measured through identical module bytes under both runtimes, `NaN`, `Infinity`, `-Infinity` and a nested `[NaN]` each take the `Ok` arm natively and the `Err` arm in the browser — a §12.9.3 divergence on the shared surface, and one no test covered because every JSON input in the parity battery is RFC-conformant. The hosts also disagree about *which call* refuses: the browser returns a handled `Err` from the parse, while natively the value parses and the following `json_stringify` raises the non-finite refusal [#1293](https://github.com/aallan/vera/issues/1293) installed, which [#1302](https://github.com/aallan/vera/issues/1302)'s host-callback path then presents as a Python traceback. Fix direction: hand `json.loads` a `parse_constant` that rejects, so the reference host refuses at the point RFC 8259 and the browser already do. | [#1306](https://github.com/aallan/vera/issues/1306) | +| `json_keys`' result cannot be used from compiled Vera. The `Some` arm of its `Option>` binds a match binder of type `Array`, and codegen emits that binder's local as `(local $lN i32_pair)` — `i32_pair` being the internal String `(ptr, len)` spelling rather than a WASM value type — so the module fails WAT assembly with `unexpected token, expected one of: i32, i64, …`. A ten-line `public fn key_count(@Json -> @Int)` matching `json_keys` and calling `array_length` on the binder passes `vera check` and fails `vera compile`; `string_join` on the same binder fails identically. Loud rather than wrong, and narrow: `array_length(map_keys(...))` compiles, so `Array` alone is fine, and `json_get_array`'s `Some(@Array)` binder compiles, so a binder alone is fine — it is the pair that breaks. Fix direction: give the binder's local the array's own value type instead of its element's. | [#1305](https://github.com/aallan/vera/issues/1305) | | A non-finite `JNumber` reaching `json_stringify` fails with the right sentence and the wrong presentation: `vera run` emits a **61-line, 3,214-character Python traceback** — 15 stack frames, 7 of them inside `.venv/`, and **no mention of the user's `.vera` file at all** — with the actual message (`json_stringify: NaN is not representable in JSON — RFC 8259 has no NaN or Infinity. …`) as its last line. A contract violation in the same program shape produces **5 lines**: the failed clause and a source backtrace with `.vera` line numbers. The buffered stdout is discarded on the way out rather than surfaced, so the `#522` output-preservation contract is lost too. The invariant this breaks is already written down, in a comment on `host_print` in `vera/codegen/api.py`: "A user-level program must never produce a Python traceback regardless of what it does — the `WasmTrapError` contract from #516/#522/#547 holds here too." Root cause: `execute()` converts an exception into `WasmTrapError` only when `type(exc).__name__` is `Trap` or `WasmtimeError`, and a `ValueError` raised inside a host callback arrives at that handler as a `ValueError`, so the branch is skipped — no classification, no source-map frame resolution, no captured output. `json_stringify` is the case that surfaced it ([#1293](https://github.com/aallan/vera/issues/1293)), but the gap is generic: any host callback raising a non-`Trap` Python exception takes the same path. Fix direction: give host-callback failures a Vera-native shape — a dedicated error type classified alongside the traps, or widen the conversion to any exception escaping a host import — carrying the captured stdout and a source-mapped backtrace either way. | [#1302](https://github.com/aallan/vera/issues/1302) | -| `md_parse` diverges between the native and browser runtimes on **211 of 1,471** adversarial inputs (14.3%) and **329 of 4,853** blank-line-separated sections of the project's own documentation (6.8%), measured by comparing the two ADTs directly rather than their renders. Nine classes, each with a one-line repro. The largest by a wide margin — 173 of the 211 — is *plain-text run grouping*: the browser emits one `MdText` per scan segment where the reference coalesces adjacent runs, so `**unclosed` is `[MdEmph([]), MdText("unclosed")]` natively and `[MdText("*"), MdText("*unclosed")]` in the browser. That class is invisible to `md_render` (the runs concatenate to the same text) but not to a Vera program that matches on the ADT, which is what makes it a §12.9.3 violation rather than a cosmetic one. The rest are render-visible: emphasis/strong scanning (`***both***` renders `**both****` natively, `***both***` in the browser); list-continuation indent width, where the reference strips exactly two (or three) characters and the browser strips all leading whitespace (`- a\n b` → `- a b` vs `- a b`); a `+` bullet, unrecognised in the browser; an `n)` ordered marker, likewise; a loose list, one list natively and two in the browser (`- a\n\n- b`); nesting past two levels, flattened in the browser (`- a\n - b\n - c`); a thematic break with internal spaces (`* * *`); and a table without a separator row (`\| a \| b \|\n- li`). Neither implementation is the specification — §9.7.3 pins the ADT, not the grammar that produces it — so closing this means choosing a parse for each class and stating it, then mirroring. Successor to [#1294](https://github.com/aallan/vera/issues/1294), which closed the `md_render` half; the parity suite pins every shape the two implementations do agree on, so the set cannot widen unnoticed. | [#1301](https://github.com/aallan/vera/issues/1301) | +| `md_parse` diverges between the native and browser runtimes on **211 of 1,471** adversarial inputs (14.3%) and **329 of 4,857** blank-line-separated sections of the project's own documentation (6.8%), measured at [PR #1303](https://github.com/aallan/vera/pull/1303) by comparing the two ADTs directly rather than their renders. The second denominator is the repository's own Markdown, so it moves whenever a document gains or loses a section; 11 of those 4,857 also differ in the rendered output. Nine classes, each with a one-line repro. The largest by a wide margin — 173 of the 211 — is *plain-text run grouping*: the browser emits one `MdText` per scan segment where the reference coalesces adjacent runs, so `**unclosed` is `[MdEmph([]), MdText("unclosed")]` natively and `[MdText("*"), MdText("*unclosed")]` in the browser. That class is invisible to `md_render` (the runs concatenate to the same text) but not to a Vera program that matches on the ADT, which is what makes it a §12.9.3 violation rather than a cosmetic one. The rest are render-visible: emphasis/strong scanning (`***both***` renders `**both****` natively, `***both***` in the browser); list-continuation indent width, where the reference strips exactly two (or three) characters and the browser strips all leading whitespace (`- a\n b` → `- a b` vs `- a b`); a `+` bullet, unrecognised in the browser; an `n)` ordered marker, likewise; a loose list, one list natively and two in the browser (`- a\n\n- b`); nesting past two levels, flattened in the browser (`- a\n - b\n - c`); a thematic break with internal spaces (`* * *`); and a table without a separator row (`\| a \| b \|\n- li`). Neither implementation is the specification — §9.7.3 pins the ADT, not the grammar that produces it — so closing this means choosing a parse for each class and stating it, then mirroring. Successor to [#1294](https://github.com/aallan/vera/issues/1294), which closed the `md_render` half; the parity suite pins the shapes they do agree on, so a regression on one of those goes red. | [#1301](https://github.com/aallan/vera/issues/1301) | | Codegen's `_known_fns` mirrors the flat `_fn_sigs`, which keys a name the call site **cannot see** under its bare name, so the #1284 ownership predicate answers "user-owned" at a site where the checker resolved the effect operation — one rule, two tables that disagree. Three demonstrated routes: a `private fn get` in an imported module (still reachable, so still emitted); a `public fn get` a selective import excludes; and a `where` helper of a **`forall` parent**, which keeps a bare `_fn_sigs` key beside its clone-qualified one (`helper`/`id` in `ch09_generic_where_helper.vera`) where a non-generic parent's helper does not. All three are check-green, and how the failure lands depends on the route and on the widths: the two import routes are the dangerous ones — where both cells are i64 the module loads and returns the invisible helper's value instead of the cell's (**silent wrong value**), and where the widths differ it fails to load (`type mismatch: expected i64, found i32`). The generic-`where` route is always **loud**: the bare key exists in the signature table while no bare symbol is emitted (the helper is only ever `holder$Bool$where$get`), so the call fails at WAT assembly with `unknown func: failed to find name $get` and no E-code. The checker's answer is nailed by a type oracle rather than asserted — the invisible `get` returns `@Bool` while the caller returns `@Int` from `get(())` and checks green, which is only possible if the checker typed the call from the `State` cell — and each route carries a rename control that returns the checker's value. Pre-existing, not introduced by the #1284 fix, which made the rule one function without making either table scope-accurate. Fix direction: `_known_fns` becomes the names visible in the compiling declaration's **lexical** scope — module scope is not enough, since a generic's `where` helper *is* in the module and still is not in a sibling's scope — or the flattening stops keying invisible names bare, the `mod$…` renaming `_resolve_module_call_wasm_name` already applies to shadowed generics being the precedent. Same module-namespace table family as [#1281](https://github.com/aallan/vera/issues/1281). Spec §7.4 states the language rule ("declarations first — a property of the call site's scope alone"); this row tracks codegen's violation of it. | [#1299](https://github.com/aallan/vera/issues/1299) | | A postcondition may name a `State` the function's effect row never declares, and `vera check` accepts it: `ensures(new(State) == false)` under `effects(>)` reports OK, then `vera compile` fails with **E699** — the internal-compiler-error diagnostic whose own text says the type checker should have rejected the input, which is exactly the situation. Both forms land there for the same reason (no cell of that family exists, so `old()` finds no snapshot local and, since [#1285](https://github.com/aallan/vera/issues/1285), `new()` finds no getter). Loud and never a wrong answer, so this is diagnostic quality rather than soundness — but it is a check-green program that cannot compile, reported against the compiler instead of against the program, with a bug-report request the user should not act on. Before #1285 the `new()` side was worse than loud: the name-keyed lookup found the row's other getter and silently read the wrong cell. Fix direction: validate an `OldExpr`/`NewExpr`'s effect reference against the declared row where the checker already validates the rest of the clause, one rule for both forms; `test_a_family_the_row_does_not_declare_is_loud_on_both_sides` pins today's E699 and is the test to flip. | [#1298](https://github.com/aallan/vera/issues/1298) | | Spec §1.4 says its reserved keywords MUST NOT be used as function names, and for **seventeen of them nothing enforces it**: `then`, `else`, `data`, `type`, `module`, `import`, `public`, `private`, `requires`, `ensures`, `invariant`, `decreases`, `effect`, `with`, `in`, `where` and `pure` all declare and pass `vera check` as `public fn (@Int -> @Int)`. (`handle` is an eighteenth acceptance and is the sanctioned host-entry-point carve-out, §5.2; the other eleven names — `resume`, `exists`, `forall`, `match`, `assert`, `assume`, `fn`, `let`, `if`, `true`, `false` — are refused **E153**.) They are not even traps: `private fn with(@Int -> @Int)` and its `where` / `type` / `pure` / `requires` / `import` siblings are **callable** from the same file, so the declaration resolves and runs like any other function. The comment above `_KEYWORD_FN_NAMES` in `vera/checker/registration.py` gives the opposite as the reason those names are absent from the set — that the contextual lexer "does not admit them as a function name, so no declaration reaches this checker at all" — so the omission rests on a premise the tree refutes, and neither the parser nor the checker holds the MUST. Two resolutions, and the choice is a ruling rather than a mechanical fix: extend `_KEYWORD_FN_NAMES` so the seventeen join `resume` under E153, or narrow §1.4's MUST to the names actually reserved. The `resume` precedent leans to enforcing — `resume` is likewise not a keyword token anywhere and likewise parses, and was reserved *at the checker* precisely because the parser had no keyword to refuse it with — and E153's own rationale, that a keyword names one construct and nothing else (DESIGN principle 3), does not depend on the name being a trap. Until the ruling lands, §1.4's `handle` carve-out sentence deliberately makes no enforcement claim, so the spec does not assert a MUST it cannot back. | [#1296](https://github.com/aallan/vera/issues/1296) | From 162cf0aad543c5e063959d478ad0e9fe37758789 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 04:11:32 +0100 Subject: [PATCH 13/69] Write back the three MdBlock values the renderer could not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three are reachable only from a value a program BUILDS, which is why the round-trip corpus never found them: the parser cannot produce the shape that breaks, so no amount of parse-derived testing reaches it. Each is fixed on both runtimes together, with the rule restated in spec §9.7.3. A code span whose content starts and ends with a space was eaten by the parser's own strip. _parse_inlines removes one such pair whenever the fenced text is two characters or longer, so MdCode(" x ") rendered as ` x ` and read back as MdCode("x") — and MdCode(" `x` ") rendered to exactly the same bytes as MdCode("`x`"), which made the loss unrecoverable even by guessing. The renderer now pads those spans the way it already padded backtick-bounded ones, so the strip removes the pad instead of the content. §9.7.3 loses one of its three documented round-trip losses as a result; the other two — a span needing three or more backticks at line start, and an empty span — stay, because the subset has no way to write either. A list item with no blocks was dropped outright, though "- " is exactly what the parser reads back as one. In an ordered list dropping it also renumbered every item after it, silently. It now renders as its marker plus the space both item patterns require; a bare "-" is a paragraph, so the space is not decoration. A container that renders to nothing — a list with no items, a table with no rows — still drew the document's blank-line separator, so MdDocument([MdList([]), p]) rendered "\nafter". A blank line standing for an absent block is one the next parse cannot attribute to anything, and it cost the render its fixed-point property. A zero-line child now contributes no separator, in MdBlockQuote as well as MdDocument. Held to a zero-regression bar over the 6,078-case Markdown corpus (1,471 adversarial inputs plus every blank-line section of the project's own documentation): on ADT agreement, render agreement, and each host's own fixed-point property, the before/after transition matrix shows no ok->broken movement in any cell. The 22 constructed shapes are additionally compared host against host directly, all agreeing. The nesting test's parse-side control is made to mean what it says. It asserted the native and browser fields against each other three lines after asserting the two whole strings equal, so it could not fail; each host is now held against the expected values instead, which a mutation of the source's heading level confirms goes red. Co-Authored-By: Claude --- CHANGELOG.md | 2 + FAQ.md | 2 +- KNOWN_ISSUES.md | 2 +- README.md | 2 +- ROADMAP.md | 2 +- TESTING.md | 6 +- docs/llms-full.txt | 2 +- spec/09-standard-library.md | 8 +- tests/test_browser.py | 143 ++++++++++++++++++++++++++++++++++-- tests/test_markdown.py | 114 ++++++++++++++++++++++++++++ vera/README.md | 4 +- vera/browser/runtime.mjs | 45 ++++++++++-- vera/markdown.py | 65 +++++++++++++--- 13 files changed, 357 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f8939e0..07a11256 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **`new(State)` reads the cell its contract names** ([#1285](https://github.com/aallan/vera/issues/1285)). `old(State)` has been keyed on the resolved cell family since [#1205](https://github.com/aallan/vera/issues/1205)/[#1209](https://github.com/aallan/vera/issues/1209); `new(State)` read the name-keyed `_effect_ops["get"]`, which holds whichever `State` the effect row registered first. Under a single-`State` row the two keyings coincide, which is why the corpus agreed; under a multi-`State` row the two sides of one `ensures` clause read different cells. `effects(, State>)` with `ensures(new(State) == false)` was check-green *and* verify-green, emitted `state_get_Int`'s i64 into the Bool comparison's `i32.eq`, and died at load with wasmtime's raw `type mismatch: expected i32, found i64`. The type mismatch is the symptom rather than the defect: where both cells share a machine width — `State` beside `State` — the module loaded and answered about the other cell, so `ensures(new(State) == old(State))` on a function that writes neither was refuted at runtime on a contract the verifier had discharged. Codegen now carries a family→getter registry populated at the declared-row registration site from the per-family `CellNames` it already computes, and `_translate_new_expr` keys on `_state_effect_family` exactly as `_translate_old_expr` does. A bare `get(())` names no family and so is right to keep reading the name-keyed registry, source-order-first-wins; a contract names one and must not. +- **Three constructed `MdBlock` values the renderer could not write back** ([#1294](https://github.com/aallan/vera/issues/1294) review). All three are reachable only from a value a program *builds*, which is why a round-trip corpus could not find them: the parser never produces the shape that breaks. **A code span whose content starts and ends with a space** was eaten by the parser's own strip — `_parse_inlines` removes one such pair whenever the fenced text is two characters or longer, so `MdCode(" x ")` rendered `` ` x ` `` and read back as `MdCode("x")`, and `MdCode(" `x` ")` rendered to the same bytes as ``MdCode("`x`")``, which made the loss unrecoverable even by guessing. The renderer now pads those spans the same way it already padded backtick-bounded ones, so the strip removes the pad instead of the content; spec §9.7.3 loses one of its three documented round-trip losses as a result. **A list item with no blocks** was dropped outright, though `- ` is exactly what the parser reads back as one — and in an ordered list dropping it silently renumbered every item after it. It now renders as its marker plus the space both item patterns require (a bare `-` is a paragraph). **A container that renders to nothing** — a list with no items, a table with no rows — still drew the document's blank-line separator, so `MdDocument([MdList([]), p])` rendered `"\nafter"`: a blank line standing for an absent block, which the next parse cannot attribute to anything and which cost the render its fixed-point property. A zero-line child now contributes no separator, in `MdBlockQuote` as well as `MdDocument`. Both runtimes move together and the two spec rules are restated; the change is held to a zero-regression bar over the 6,078-case Markdown corpus — ADT agreement, render agreement and each host's fixed-point property all show no ok→broken transition. + - **A block quote separates and keeps its children, on both runtimes** ([#1294](https://github.com/aallan/vera/issues/1294) review). The **reference** renderer emitted no separator between a blockquote's children, so `MdBlockQuote([Para, Para])` — the shape `md_parse` builds from `> a\n>\n> b` — rendered as two adjacent quoted lines and read back as **one** paragraph. Structure lost silently, on both hosts, and the round-trip property spec §9.7.3 states did not hold for it. A quote with no children was the same defect one size down: it rendered as no lines at all, so a `>` in a document vanished on the round trip and left the enclosing separator dangling (`---\n>` came back as `---`). Both arms now mirror `MdDocument`'s: a bare `>` between children, and `>` for an empty quote. Two more mirrors land with them. The **browser parser**'s blockquote reader required `> ` with the space where the reference accepts `^>\s?`, and had no lazy-continuation branch at all, so `>no space` parsed as literal text and `> a\nb` pushed the second line out of the quote — both inside #1294's stated scope and neither closed by the renderer fix. And a **code span** is now fenced with one backtick more than its longest internal run rather than a fixed two, padded only when the content starts or ends with a backtick: the old rule was right for one backtick and wrong for two, since ``` `` a``b `` ``` closes on the run *inside* the content. The browser's inline parser scanned for the next single backtick rather than a run of equal length, so it could not read that back either; it now counts runs like the reference. Spec §9.7.3 states all four rules. Measured on a 1,471-input adversarial corpus: `md_render` is a fixed point on **every** input on both hosts (from 65 and 54 unstable), and cross-host render divergence falls from 489 inputs to 34; on 4,850 sections of the project's own documentation, render divergence falls from 40 to 11 and the reference round trip is a fixed point everywhere. The remaining `md_parse` divergences are a separate tracked bug, listed in `KNOWN_ISSUES.md` with a measured class-by-class breakdown. Alongside them: the browser's `json_stringify` no longer falls back to `"null"` when `JSON.stringify` returns `undefined` — unreachable from `readJson`, but the same silent substitution [#1293](https://github.com/aallan/vera/issues/1293) removed one layer up, and an unreachable branch is where a silent wrong answer survives; and the non-finite parity test now asserts the *whole* shared sentence, taken from the reference implementation so the browser's hand-copied duplicate is held against the original, plus that neither host printed anything before failing. diff --git a/FAQ.md b/FAQ.md index 4c073154..df340d1d 100644 --- a/FAQ.md +++ b/FAQ.md @@ -279,7 +279,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 10,729 tests, including a 216-program conformance suite +- 10,759 tests, including a 216-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 1cd87786..cc151017 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -11,7 +11,7 @@ Defects in shipped compiler, runtime, or tooling behaviour — this table matche | `json_parse` accepts `NaN`, `Infinity` and `-Infinity` on the reference runtime and refuses them in the browser. The reference host calls `json.loads`, whose default `parse_constant` admits all three; the browser host calls `JSON.parse`, which refuses them as RFC 8259 requires. Measured through identical module bytes under both runtimes, `NaN`, `Infinity`, `-Infinity` and a nested `[NaN]` each take the `Ok` arm natively and the `Err` arm in the browser — a §12.9.3 divergence on the shared surface, and one no test covered because every JSON input in the parity battery is RFC-conformant. The hosts also disagree about *which call* refuses: the browser returns a handled `Err` from the parse, while natively the value parses and the following `json_stringify` raises the non-finite refusal [#1293](https://github.com/aallan/vera/issues/1293) installed, which [#1302](https://github.com/aallan/vera/issues/1302)'s host-callback path then presents as a Python traceback. Fix direction: hand `json.loads` a `parse_constant` that rejects, so the reference host refuses at the point RFC 8259 and the browser already do. | [#1306](https://github.com/aallan/vera/issues/1306) | | `json_keys`' result cannot be used from compiled Vera. The `Some` arm of its `Option>` binds a match binder of type `Array`, and codegen emits that binder's local as `(local $lN i32_pair)` — `i32_pair` being the internal String `(ptr, len)` spelling rather than a WASM value type — so the module fails WAT assembly with `unexpected token, expected one of: i32, i64, …`. A ten-line `public fn key_count(@Json -> @Int)` matching `json_keys` and calling `array_length` on the binder passes `vera check` and fails `vera compile`; `string_join` on the same binder fails identically. Loud rather than wrong, and narrow: `array_length(map_keys(...))` compiles, so `Array` alone is fine, and `json_get_array`'s `Some(@Array)` binder compiles, so a binder alone is fine — it is the pair that breaks. Fix direction: give the binder's local the array's own value type instead of its element's. | [#1305](https://github.com/aallan/vera/issues/1305) | | A non-finite `JNumber` reaching `json_stringify` fails with the right sentence and the wrong presentation: `vera run` emits a **61-line, 3,214-character Python traceback** — 15 stack frames, 7 of them inside `.venv/`, and **no mention of the user's `.vera` file at all** — with the actual message (`json_stringify: NaN is not representable in JSON — RFC 8259 has no NaN or Infinity. …`) as its last line. A contract violation in the same program shape produces **5 lines**: the failed clause and a source backtrace with `.vera` line numbers. The buffered stdout is discarded on the way out rather than surfaced, so the `#522` output-preservation contract is lost too. The invariant this breaks is already written down, in a comment on `host_print` in `vera/codegen/api.py`: "A user-level program must never produce a Python traceback regardless of what it does — the `WasmTrapError` contract from #516/#522/#547 holds here too." Root cause: `execute()` converts an exception into `WasmTrapError` only when `type(exc).__name__` is `Trap` or `WasmtimeError`, and a `ValueError` raised inside a host callback arrives at that handler as a `ValueError`, so the branch is skipped — no classification, no source-map frame resolution, no captured output. `json_stringify` is the case that surfaced it ([#1293](https://github.com/aallan/vera/issues/1293)), but the gap is generic: any host callback raising a non-`Trap` Python exception takes the same path. Fix direction: give host-callback failures a Vera-native shape — a dedicated error type classified alongside the traps, or widen the conversion to any exception escaping a host import — carrying the captured stdout and a source-mapped backtrace either way. | [#1302](https://github.com/aallan/vera/issues/1302) | -| `md_parse` diverges between the native and browser runtimes on **211 of 1,471** adversarial inputs (14.3%) and **329 of 4,857** blank-line-separated sections of the project's own documentation (6.8%), measured at [PR #1303](https://github.com/aallan/vera/pull/1303) by comparing the two ADTs directly rather than their renders. The second denominator is the repository's own Markdown, so it moves whenever a document gains or loses a section; 11 of those 4,857 also differ in the rendered output. Nine classes, each with a one-line repro. The largest by a wide margin — 173 of the 211 — is *plain-text run grouping*: the browser emits one `MdText` per scan segment where the reference coalesces adjacent runs, so `**unclosed` is `[MdEmph([]), MdText("unclosed")]` natively and `[MdText("*"), MdText("*unclosed")]` in the browser. That class is invisible to `md_render` (the runs concatenate to the same text) but not to a Vera program that matches on the ADT, which is what makes it a §12.9.3 violation rather than a cosmetic one. The rest are render-visible: emphasis/strong scanning (`***both***` renders `**both****` natively, `***both***` in the browser); list-continuation indent width, where the reference strips exactly two (or three) characters and the browser strips all leading whitespace (`- a\n b` → `- a b` vs `- a b`); a `+` bullet, unrecognised in the browser; an `n)` ordered marker, likewise; a loose list, one list natively and two in the browser (`- a\n\n- b`); nesting past two levels, flattened in the browser (`- a\n - b\n - c`); a thematic break with internal spaces (`* * *`); and a table without a separator row (`\| a \| b \|\n- li`). Neither implementation is the specification — §9.7.3 pins the ADT, not the grammar that produces it — so closing this means choosing a parse for each class and stating it, then mirroring. Successor to [#1294](https://github.com/aallan/vera/issues/1294), which closed the `md_render` half; the parity suite pins the shapes they do agree on, so a regression on one of those goes red. | [#1301](https://github.com/aallan/vera/issues/1301) | +| `md_parse` diverges between the native and browser runtimes on **211 of 1,471** adversarial inputs (14.3%) and **329 of 4,858** blank-line-separated sections of the project's own documentation (6.8%), measured at [PR #1303](https://github.com/aallan/vera/pull/1303) by comparing the two ADTs directly rather than their renders. The second denominator is the repository's own Markdown, so it moves whenever a document gains or loses a section; 11 of those 4,858 also differ in the rendered output. Nine classes, each with a one-line repro. The largest by a wide margin — 173 of the 211 — is *plain-text run grouping*: the browser emits one `MdText` per scan segment where the reference coalesces adjacent runs, so `**unclosed` is `[MdEmph([]), MdText("unclosed")]` natively and `[MdText("*"), MdText("*unclosed")]` in the browser. That class is invisible to `md_render` (the runs concatenate to the same text) but not to a Vera program that matches on the ADT, which is what makes it a §12.9.3 violation rather than a cosmetic one. The rest are render-visible: emphasis/strong scanning (`***both***` renders `**both****` natively, `***both***` in the browser); list-continuation indent width, where the reference strips exactly two (or three) characters and the browser strips all leading whitespace (`- a\n b` → `- a b` vs `- a b`); a `+` bullet, unrecognised in the browser; an `n)` ordered marker, likewise; a loose list, one list natively and two in the browser (`- a\n\n- b`); nesting past two levels, flattened in the browser (`- a\n - b\n - c`); a thematic break with internal spaces (`* * *`); and a table without a separator row (`\| a \| b \|\n- li`). Neither implementation is the specification — §9.7.3 pins the ADT, not the grammar that produces it — so closing this means choosing a parse for each class and stating it, then mirroring. Successor to [#1294](https://github.com/aallan/vera/issues/1294), which closed the `md_render` half; the parity suite pins the shapes they do agree on, so a regression on one of those goes red. | [#1301](https://github.com/aallan/vera/issues/1301) | | Codegen's `_known_fns` mirrors the flat `_fn_sigs`, which keys a name the call site **cannot see** under its bare name, so the #1284 ownership predicate answers "user-owned" at a site where the checker resolved the effect operation — one rule, two tables that disagree. Three demonstrated routes: a `private fn get` in an imported module (still reachable, so still emitted); a `public fn get` a selective import excludes; and a `where` helper of a **`forall` parent**, which keeps a bare `_fn_sigs` key beside its clone-qualified one (`helper`/`id` in `ch09_generic_where_helper.vera`) where a non-generic parent's helper does not. All three are check-green, and how the failure lands depends on the route and on the widths: the two import routes are the dangerous ones — where both cells are i64 the module loads and returns the invisible helper's value instead of the cell's (**silent wrong value**), and where the widths differ it fails to load (`type mismatch: expected i64, found i32`). The generic-`where` route is always **loud**: the bare key exists in the signature table while no bare symbol is emitted (the helper is only ever `holder$Bool$where$get`), so the call fails at WAT assembly with `unknown func: failed to find name $get` and no E-code. The checker's answer is nailed by a type oracle rather than asserted — the invisible `get` returns `@Bool` while the caller returns `@Int` from `get(())` and checks green, which is only possible if the checker typed the call from the `State` cell — and each route carries a rename control that returns the checker's value. Pre-existing, not introduced by the #1284 fix, which made the rule one function without making either table scope-accurate. Fix direction: `_known_fns` becomes the names visible in the compiling declaration's **lexical** scope — module scope is not enough, since a generic's `where` helper *is* in the module and still is not in a sibling's scope — or the flattening stops keying invisible names bare, the `mod$…` renaming `_resolve_module_call_wasm_name` already applies to shadowed generics being the precedent. Same module-namespace table family as [#1281](https://github.com/aallan/vera/issues/1281). Spec §7.4 states the language rule ("declarations first — a property of the call site's scope alone"); this row tracks codegen's violation of it. | [#1299](https://github.com/aallan/vera/issues/1299) | | A postcondition may name a `State` the function's effect row never declares, and `vera check` accepts it: `ensures(new(State) == false)` under `effects(>)` reports OK, then `vera compile` fails with **E699** — the internal-compiler-error diagnostic whose own text says the type checker should have rejected the input, which is exactly the situation. Both forms land there for the same reason (no cell of that family exists, so `old()` finds no snapshot local and, since [#1285](https://github.com/aallan/vera/issues/1285), `new()` finds no getter). Loud and never a wrong answer, so this is diagnostic quality rather than soundness — but it is a check-green program that cannot compile, reported against the compiler instead of against the program, with a bug-report request the user should not act on. Before #1285 the `new()` side was worse than loud: the name-keyed lookup found the row's other getter and silently read the wrong cell. Fix direction: validate an `OldExpr`/`NewExpr`'s effect reference against the declared row where the checker already validates the rest of the clause, one rule for both forms; `test_a_family_the_row_does_not_declare_is_loud_on_both_sides` pins today's E699 and is the test to flip. | [#1298](https://github.com/aallan/vera/issues/1298) | | Spec §1.4 says its reserved keywords MUST NOT be used as function names, and for **seventeen of them nothing enforces it**: `then`, `else`, `data`, `type`, `module`, `import`, `public`, `private`, `requires`, `ensures`, `invariant`, `decreases`, `effect`, `with`, `in`, `where` and `pure` all declare and pass `vera check` as `public fn (@Int -> @Int)`. (`handle` is an eighteenth acceptance and is the sanctioned host-entry-point carve-out, §5.2; the other eleven names — `resume`, `exists`, `forall`, `match`, `assert`, `assume`, `fn`, `let`, `if`, `true`, `false` — are refused **E153**.) They are not even traps: `private fn with(@Int -> @Int)` and its `where` / `type` / `pure` / `requires` / `import` siblings are **callable** from the same file, so the declaration resolves and runs like any other function. The comment above `_KEYWORD_FN_NAMES` in `vera/checker/registration.py` gives the opposite as the reason those names are absent from the set — that the contextual lexer "does not admit them as a function name, so no declaration reaches this checker at all" — so the omission rests on a premise the tree refutes, and neither the parser nor the checker holds the MUST. Two resolutions, and the choice is a ruling rather than a mechanical fix: extend `_KEYWORD_FN_NAMES` so the seventeen join `resume` under E153, or narrow §1.4's MUST to the names actually reserved. The `resume` precedent leans to enforcing — `resume` is likewise not a keyword token anywhere and likewise parses, and was reserved *at the checker* precisely because the parser had no keyword to refuse it with — and E153's own rationale, that a keyword names one construct and nothing else (DESIGN principle 3), does not depend on the name being a trap. Until the ruling lands, §1.4's `handle` carve-out sentence deliberately makes no enforcement claim, so the spec does not assert a MUST it cannot back. | [#1296](https://github.com/aallan/vera/issues/1296) | diff --git a/README.md b/README.md index 5a875ccd..7e914b2a 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ cp /path/to/vera/SKILL.md ~/.claude/skills/vera-language/SKILL.md ## Project status -Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 10,729 tests, 95% Python code coverage, 216 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. +Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 10,759 tests, 95% Python code coverage, 216 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. The reference compiler — parser, AST, type checker, contract verifier (Z3), WASM code generator, module system, browser runtime, and runtime contract insertion — is working. The language specification is in draft across [14 chapters](spec/). diff --git a/ROADMAP.md b/ROADMAP.md index f7a6945e..641e81fb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,7 +12,7 @@ Ordering derives from the design principles ([DESIGN.md](DESIGN.md)): verificati ## Where we are -10,729 tests, 216 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. +10,759 tests, 216 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. ## Stage 19 — The verification completeness sprint diff --git a/TESTING.md b/TESTING.md index cc8e0ad3..cdf12e6a 100644 --- a/TESTING.md +++ b/TESTING.md @@ -6,7 +6,7 @@ This is the single source of truth for Vera's testing infrastructure, coverage d | Metric | Value | |--------|-------| -| **Tests** | 10,729 across 164 files (~155,000 lines of test code; 10,561 passed + 26 stress, 142 skipped) | +| **Tests** | 10,759 across 164 files (~155,000 lines of test code; 10,591 passed + 26 stress, 142 skipped) | | **Compiler code coverage** | 95% Python, 87% JavaScript (CI minimum: 80%) | | **Conformance programs** | 216 programs across 9 spec chapters, validating every language feature | | **Example programs** | 42, all validated through `vera check` + `vera verify` | @@ -194,9 +194,9 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_tester.py` | 17 | 445 | Contract-driven testing: tier classification, input generation, test execution, skip message content | | `test_tester_artifacts.py` | 1 | 89 | `vera test` compiles through the same artifact tables as the other CLI doors: the tester-compiled WAT for a tuple-component widening carries the widen guard (without the shared artifact tables, `cmd_test` emits no guard while the verifier claims Tier-3 runtime-guarded — a verifier↔codegen divergence) | | `test_tester_coverage.py` | 49 | 1,395 | Tester coverage gaps: String/Float64/ADT parameter input generation, Bool/Byte parameters, unsatisfiable preconditions, type expression edge cases, FP model-value extraction (NaN/Inf/signed-zero, #797), the #1208 naming pin (the threaded alias environment canonicalizes a type-argument alias), and the #1216 resolution set — an alias-typed parameter is trialed rather than skipped, its alias-spelled `requires` constrains the generated inputs, a refined alias reaches Z3 with its predicate so no trial violates codegen's entry guard, a `forall` variable shadowing a same-named module alias still resolves to an unsupported type variable, and a parameter that resolves to a non-encodable type still skips with that type named; plus the #1229 set — an UNTRANSLATABLE input constraint skips the function naming the blocking conjunct instead of scoring the resulting entry-guard trap as a contract failure, on the reported `string_length` repro, on a mixed clause (only the untranslatable conjunct is named), on a quantified precondition and on a refined parameter's predicate, with a Tier-3 control that must still be tested so a skip taxonomy firing on everything cannot pass | -| `test_markdown.py` | 78 | 496 | Markdown parser: block/inline parsing, rendering, round-trips, edge cases | +| `test_markdown.py` | 94 | 610 | Markdown parser: block/inline parsing, rendering, round-trips, edge cases | | `test_lsp.py` | 146 | 2470 | LSP transport + coordinate layer (#222 Phase C) and language features (#222 Phase D): parametrized code-point↔UTF-16 goldens incl. astral-plane fixtures and surrogate-pair snapping, Span (1-based, exclusive-end) and SourceLocation (0-based col) → LSP Range conversions, point→token-range widening, DocumentStore open/change/close + index invalidation, an in-process handler-drive test, and one stdio end-to-end round-trip against the real `vera lsp` subprocess (initialize → didOpen → shutdown → exit) pinning serverInfo + textDocumentSync capabilities; plus the Phase D feature suite — parse-error single-diagnostic path, type-error verification short-circuit, tier=3 in E520 diagnostic data, per-function tier Hint synthesis (and its suppression for functions with violated obligations), smallest-enclosing-span hover, De Bruijn slot goto (most-recent-parameter jump, out-of-range None, off-slot None, and the #1208 keying pins: a parameterised reference resolves, an alias-spelled parameter is reachable from a canonically-spelled reference, and a `forall` variable shadowing a module alias lands on the right parameter), and typed-hole completion (inside/after hole, away-from-hole None); plus the Phase E speculativeEdit suite — identical-text all-unchanged, breaking edit surfaces newly_undischarged (violated nat_sub) with canonical state untouched, strengthening edit surfaces newly_discharged, parse/type errors report ok:false, deleted functions report removed, proof_delta purity; plus the Phase F1 proposeEdit suite — the apply gate (clean and strengthening edits apply, breaking and non-compiling edits refuse), force overriding both gates with the delta still reported, wiring against a structural fake server (apply round-trip with exact full-document replacement range, refuse touches no canonical state, unopened-URI clamp sentinel), and full-document-range goldens (trailing-newline virtual line, UTF-16 end column); plus the Phase F2 strengthenContract suite — splice goldens (first-clause-only replacement with byte-identical remainder, ensures variant, unknown-fn None), the call-site audit pin (tightened precondition refused with newly_undischarged call_pre items, canonical state untouched), provable-ensures strengthening applies, and the three splice-target refusal paths (no analysis, unparseable document, unknown function); plus the Phase F3 addEffect suite — transitive-caller closure goldens (diamond in declaration order, leaf, unknown-fn None, recursion appears once), handler bounding (#725: a caller discharging the effect around its only call site drops out of the closure and is not rewritten, while a second unhandled path, a call in a handler clause, a handler for another effect, a handler naming a different instance of the same effect (`handle[State]` against a `State` propagation, which the checker does not discharge — end-to-end that caller must still be rewritten and the candidate must still apply, with the matching `State` propagation against the same fixture as the positive control that this handler key does prune something), and a bare `where`-helper call all keep it in, as does a refinement type argument at either depth (`Exn<{ @Int \| p }>` and `Exn>` render as their bare base type but discharge nothing of `Exn` — also pinned end-to-end), while an unparameterised `handle[IO]` does bound an `IO` propagation, a whitespace-spelled `State< Int >` request still bounds, and nesting bounds in either order (a matching handler inside a foreign one, a foreign one inside a matching one) — plus a key-level pin that `handle[Mod.IO]` keeps its module, and the effect-less query pinned handler-unaware; plus the two boundary pins the review added — a call in the handler's STATE INITIALISER keeps its edge, since the initialiser is evaluated in the enclosing scope before the handler is installed (pinned beside the E125 the checker raises there against a `pure` caller, with the identical call in the handler body clean as the contrast), and an alias-spelled handler does not bound a `State` propagation though the checker discharges it (`handle[State]` with `type MyAlias = Int` — the spelling comparison's under-prune, #1292, with the alias-spelled request as the control that does prune)), effect-row rewrite goldens (pure to singleton set, source-preserving append, already-present None, base-name identity blocking State next to State), diamond propagation applying one multi-site candidate with the bystander untouched, mixed append/replace rows with already-satisfied callers skipped, the fully-satisfied no-op shape, and the two refusal paths; plus the #728 instruction-contract suite — the LSP message carries description, rationale, and the Fix: paragraph (also pinning single E501 emission at the LSP surface), and a bare diagnostic maps to the description alone | -| `test_browser.py` | 289 | 4,478 | Browser parity: Python/wasmtime vs Node.js/JS-runtime output equivalence across IO, State, contracts, Markdown, Regex, and the examples the browser target can execute (two explicit lists in the file, not the whole `examples/` directory — interactive stdin, file IO, `DB` and the non-standalone `modules` example are excluded with their reasons recorded); plus the #349 `runtime.mjs` coverage battery — per-value-type and per-key-type Map variants, per-element-type Set variants, cold `Decimal` branches (exact-zero sign, negative-shift division, `decRoundPlaces` special cases, non-finite storage), `readJson`/`json_stringify` across every Json ADT tag, the Regex/Json `Result.Err` arms, and nested-Markdown walks. Two operations carry a canonical form the specification states rather than merely agreeing across the hosts, so their batteries assert more than equality: `json_stringify` (spec §9.7.1) pins the expected string on every Json ADT tag and on the number-rendering boundaries, checks three-pass idempotence, checks that a non-finite `JNumber` fails on both hosts *and* prints nothing, pins the object key orders an ordinary JS object cannot carry (array-index keys, which ES enumeration hoists to the front in ascending numeric order, and a `__proto__` key, whose assignment writes a prototype instead of a field) on both a parsed and a program-built object, and checks the reference host's own number rendering differentially against a real `JSON.stringify` over doubles drawn from raw bit patterns; `md_render` (§9.7.3) pins the expected render, re-renders it to prove the fixed point, runs the round-trip property over a corpus carrying the container and multi-line shapes a flat corpus misses, and renders `MdBlock` values the test builds directly, since several renderer rules — a container's child separator, an empty container, a code span wider than one backtick — are unreachable through `md_parse`. `md_parse` itself is not yet at parity and its remaining divergence classes are a tracked bug ([#1301](https://github.com/aallan/vera/issues/1301)); the suite pins the shapes the two implementations agree on | +| `test_browser.py` | 303 | 4,605 | Browser parity: Python/wasmtime vs Node.js/JS-runtime output equivalence across IO, State, contracts, Markdown, Regex, and the examples the browser target can execute (two explicit lists in the file, not the whole `examples/` directory — interactive stdin, file IO, `DB` and the non-standalone `modules` example are excluded with their reasons recorded); plus the #349 `runtime.mjs` coverage battery — per-value-type and per-key-type Map variants, per-element-type Set variants, cold `Decimal` branches (exact-zero sign, negative-shift division, `decRoundPlaces` special cases, non-finite storage), `readJson`/`json_stringify` across every Json ADT tag, the Regex/Json `Result.Err` arms, and nested-Markdown walks. Two operations carry a canonical form the specification states rather than merely agreeing across the hosts, so their batteries assert more than equality: `json_stringify` (spec §9.7.1) pins the expected string on every Json ADT tag and on the number-rendering boundaries, checks three-pass idempotence, checks that a non-finite `JNumber` fails on both hosts *and* prints nothing, pins the object key orders an ordinary JS object cannot carry (array-index keys, which ES enumeration hoists to the front in ascending numeric order, and a `__proto__` key, whose assignment writes a prototype instead of a field) on both a parsed and a program-built object, and checks the reference host's own number rendering differentially against a real `JSON.stringify` over doubles drawn from raw bit patterns; `md_render` (§9.7.3) pins the expected render, re-renders it to prove the fixed point, runs the round-trip property over a corpus carrying the container and multi-line shapes a flat corpus misses, and renders `MdBlock` values the test builds directly, since several renderer rules — a container's child separator, an empty container, a code span wider than one backtick — are unreachable through `md_parse`. `md_parse` itself is not yet at parity and its remaining divergence classes are a tracked bug ([#1301](https://github.com/aallan/vera/issues/1301)); the suite pins the shapes the two implementations agree on | | `test_conformance.py` | 1080 | 125 | Parametrized conformance suite: parse, check, verify, run, format idempotency across 216 programs | | `test_prelude.py` | 29 | 585 | Prelude injection: Option/Result/array operation detection, combinator shadowing, type aliases, the reserved namespace every injected alias declaration lives in — checked against the checker's own E154 regex rather than a second spelling of the rule, since an alias the prelude injects outside it is one codegen resolves and the checker leaves opaque (#1184/#1221) — end-to-end compilation | | `test_checker_apply_fn.py` | 18 | 455 | #854 — `apply_fn` as a checker special form: zero-warning pins (API + CLI `--json` + closures.vera), E201 arity / E202 type / non-function-first-arg errors, E122/E125 effect-row enforcement for applied fn values, E151 redefinition rejection, variadic two-param application, prelude combinator regression pins | diff --git a/docs/llms-full.txt b/docs/llms-full.txt index d0338e79..669fb2d4 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -3256,7 +3256,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 10,729 tests, including a 216-program conformance suite +- 10,759 tests, including a 216-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/spec/09-standard-library.md b/spec/09-standard-library.md index a3b332fc..e760ddd1 100644 --- a/spec/09-standard-library.md +++ b/spec/09-standard-library.md @@ -2267,18 +2267,18 @@ public fn md_render(@MdBlock -> @String) effects(pure) ``` -Renders an `MdBlock` to a canonical Markdown string. Always succeeds. For every block the subset can write back — all but the three code-span shapes named below — the round-trip property `md_parse(md_render(b)) == Ok(b)` should hold: rendering then re-parsing preserves structure. Every runtime produces the same string (§12.9.3). +Renders an `MdBlock` to a canonical Markdown string. Always succeeds. For every block the subset can write back — all but the two code-span shapes named below — the round-trip property `md_parse(md_render(b)) == Ok(b)` should hold: rendering then re-parsing preserves structure. Every runtime produces the same string (§12.9.3). Four rules carry that property. The first two follow from the ADT having no line-break node (see the design note above); the last two from a container needing to be readable back as one block: - **A paragraph is one line.** Its inline content is rendered without internal newlines. A parser collapses a paragraph's soft line breaks to spaces on the way in, so nothing survives to be re-emitted. - **A container prefixes every line of every child.** A block quote writes `>` and a space before each rendered line, and a bare `>` for a blank one; a list item writes its marker before the first line and an equal-width indent before the rest. Prefixing only a child's first line would leave a fenced block's body — or a nested block's continuation — outside its container, and the next `md_parse` would read it as a sibling. -- **A container separates its children.** A block quote writes a bare `>` between adjacent children, as a document writes a blank line between its own. Without it two quoted paragraphs render as two adjacent quoted lines, which re-parse as one paragraph. An empty container still occupies its line: a `MdBlockQuote` with no children renders `>`, because rendering it as nothing deletes the block and leaves the enclosing separator dangling. -- **A code span is fenced longer than its content.** The fence is one backtick more than the longest backtick run inside the span, with a single padding space on each side when the content itself starts or ends with a backtick. A fixed-width fence terminates on a run inside the content. +- **A container separates its children.** A block quote writes a bare `>` between adjacent children, as a document writes a blank line between its own. Without it two quoted paragraphs render as two adjacent quoted lines, which re-parse as one paragraph. An empty child still occupies its line: a `MdBlockQuote` with no children renders `>`, and a list item with no blocks renders its marker followed by a space, which is what the item patterns read back — a bare `-` is a paragraph. Rendering either as nothing deletes it, and in an ordered list renumbers every item after it. A container with nothing to render at all — a list with no items, a table with no rows — contributes no lines **and** no separator, because a separator standing for an absent block is a blank line the next parse cannot attribute to anything. +- **A code span is fenced longer than its content.** The fence is one backtick more than the longest backtick run inside the span, with a single padding space on each side when the content itself starts or ends with a backtick, or when it both starts and ends with a space — a parser strips one such pair, so the pad is what it removes instead of the content's own spaces. A fixed-width fence terminates on a run inside the content. Together these should make `md_render` a fixed point: re-parsing and re-rendering its output returns the same bytes. -Three code-span shapes are outside that, and are lost identically on every runtime rather than differently, because the subset has no escape syntax to write them another way: a span needing three or more backticks, at the start of a line, where that run is a fenced-code-block opener; an empty span, whose rendering reads back as literal text; and a span whose content itself begins and ends with a space, since a parser strips one such pair unconditionally. +Two code-span shapes are outside that, and are lost identically on every runtime rather than differently, because the subset has no escape syntax to write them another way: a span needing three or more backticks, at the start of a line, where that run is a fenced-code-block opener; and an empty span, whose rendering reads back as literal text. **Accessor functions for contracts:** diff --git a/tests/test_browser.py b/tests/test_browser.py index 24e842a4..3a330a0e 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -4068,10 +4068,11 @@ class TestBrowserMarkdownNesting349: ``md_render`` used to diverge on exactly this input (#1294), so the rendered prefix was asserted browser-side only. It now agrees, and the whole string is compared across the two runtimes. The three - parse-side fields are still compared separately as well: they were - the control that scoped #1294 to the renderer, and keeping them - named means a future renderer regression cannot be mistaken for a - parser one. + parse-side fields are still checked separately as well — each host's + against the expected values, since the whole-string equality already + makes the two sides the same text: they were the control that scoped + #1294 to the renderer, and keeping them named means a future + renderer regression cannot be mistaken for a parser one. """ # A blockquote wrapping an h2 and a fenced block, so the recursive @@ -4101,14 +4102,18 @@ def test_nested_walks_and_list_continuations(self, tmp_path: Path) -> None: rendered, has_h2, has_py, n_blocks = browser.rsplit("|", 3) # The parse-side fields kept as a named control: they are what # scoped #1294 to the renderer, so a future divergence can still - # be told apart from a parser one. - assert native.rsplit("|", 3)[1:] == [has_h2, has_py, n_blocks] + # be told apart from a parser one. Each host is held against + # the EXPECTED values, not against the other one (#1303 review): + # the equality above already makes the two strings identical, so + # a second cross-host comparison of fields sliced out of them + # asserts nothing at all. + expected_fields = ["true", "true", "1"] + assert native.rsplit("|", 3)[1:] == expected_fields + assert [has_h2, has_py, n_blocks] == expected_fields # Continuation lines survived the per-item loop in both list kinds # *and* stayed inside their item, which is the renderer's half. assert "- first continued" in rendered assert "1. one also one" in rendered - # Recursive descents found the h2 and the fence inside the quote. - assert (has_h2, has_py, n_blocks) == ("true", "true", "1") _MD_ROUND_TRIP_PRELUDE = r""" @@ -4377,6 +4382,128 @@ def test_code_span_parses_by_run_length( """ assert _parity_stdout(src, tmp_path, f"md_span_{case_id}") == expected + @pytest.mark.parametrize(("case_id", "code", "rendered"), [ + ("both_ends", " x ", "` x `"), + ("all_spaces", " ", "` `"), + ("wider", " x ", "` x `"), + # One space is below the parser's two-character strip threshold, + # so it is neither stripped nor padded. + ("single_space", " ", "` `"), + # Space padding and backtick padding are one space, not two. + ("spaced_backticks", " `x` ", "`` `x` ``"), + # Only one end is a space — nothing is stripped, nothing padded. + ("leading_only", " a", "` a`"), + ("trailing_only", "a ", "`a `"), + ]) + def test_code_span_pads_space_bounded_content( + self, case_id: str, code: str, rendered: str, tmp_path: Path, + ) -> None: + """A span whose content starts *and* ends with a space (#1303 + review). + + Both parsers strip one such pair unconditionally, so without a + matching pad on the way out the content's own spaces are eaten: + ``MdCode(" x ")`` rendered ``` ` x ` ``` and read back as + ``MdCode("x")``. Constructed, because the shape is unreachable + by parsing — the strip removes it on the way in — which is why + the round-trip corpus never produced it on either host. + """ + src = f""" +public fn main(@Unit -> @Unit) + requires(true) ensures(true) effects() +{{ + IO.print(md_render(MdDocument([MdParagraph([MdCode("{code}")])]))) +}} +""" + assert _parity_stdout(src, tmp_path, f"md_sp_{case_id}") == rendered + + def test_code_span_padding_keeps_two_values_apart( + self, tmp_path: Path, + ) -> None: + """``MdCode(" `x` ")`` and ``MdCode("`x`")`` used to render to + the same bytes on both hosts, so the loss was not recoverable + even by guessing. Asserted as a *difference*, which a pair of + per-value expected strings would not catch if both were equal. + """ + src = """ +public fn main(@Unit -> @Unit) + requires(true) ensures(true) effects() +{ + IO.print(string_concat( + md_render(MdDocument([MdParagraph([MdCode(" `x` ")])])), + string_concat("|", + md_render(MdDocument([MdParagraph([MdCode("`x`")])]))))) +} +""" + out = _parity_stdout(src, tmp_path, "md_sp_distinct") + spaced, bare = out.split("|") + assert spaced != bare + assert (spaced, bare) == ("`` `x` ``", "`` `x` ``") + + @pytest.mark.parametrize(("case_id", "expr", "rendered"), [ + ("only_item", "MdList(false, [[]])", "- "), + ( + "empty_then_full", + 'MdList(false, [[], [MdParagraph([MdText("b")])]])', + "- \n- b", + ), + # The ordered case corrupts silently: dropping the empty item + # renumbers every item after it. + ( + "ordered_middle", + 'MdList(true, [[MdParagraph([MdText("a")])], [], ' + '[MdParagraph([MdText("c")])]])', + "1. a\n2. \n3. c", + ), + ]) + def test_empty_list_item_keeps_its_place( + self, case_id: str, expr: str, rendered: str, tmp_path: Path, + ) -> None: + """An item with no blocks is a value the *parser* produces — + ``- `` reads back as one empty item — so the renderer owes it a + form (#1303 review). Both hosts dropped it, which deleted the + item and, in an ordered list, renumbered the rest. + """ + src = f""" +public fn main(@Unit -> @Unit) + requires(true) ensures(true) effects() +{{ + IO.print(md_render(MdDocument([{expr}]))) +}} +""" + assert _parity_stdout(src, tmp_path, f"md_ei_{case_id}") == rendered + + @pytest.mark.parametrize(("case_id", "expr", "rendered"), [ + ("list_then_para", + 'MdList(false, []), MdParagraph([MdText("after")])', "after"), + ("para_then_list", + 'MdParagraph([MdText("before")]), MdList(false, [])', "before"), + ("table_between", + 'MdParagraph([MdText("a")]), MdTable([]), ' + 'MdParagraph([MdText("b")])', "a\n\nb"), + ]) + def test_zero_line_child_takes_no_separator( + self, case_id: str, expr: str, rendered: str, tmp_path: Path, + ) -> None: + """A list with no items and a table with no rows render to + nothing, and must not drag the document separator in with them + (#1303 review). + + Counting them left a blank line standing for an absent block, + which the next parse cannot attribute to anything — so the + render stopped being a fixed point. The expected strings here + have no leading or interior stray blank line, which is what the + assertion is really about. + """ + src = f""" +public fn main(@Unit -> @Unit) + requires(true) ensures(true) effects() +{{ + IO.print(md_render(MdDocument([{expr}]))) +}} +""" + assert _parity_stdout(src, tmp_path, f"md_zl_{case_id}") == rendered + def test_empty_blockquote_still_occupies_a_line( self, tmp_path: Path, ) -> None: diff --git a/tests/test_markdown.py b/tests/test_markdown.py index 4f8e2824..28263e98 100644 --- a/tests/test_markdown.py +++ b/tests/test_markdown.py @@ -11,6 +11,7 @@ MdBlockQuote, MdCode, MdCodeBlock, + MdDocument, MdEmph, MdHeading, MdImage, @@ -408,6 +409,119 @@ def test_code_span_needing_a_triple_fence_collides_at_line_start( assert line == "```a``b```" assert parse_markdown(line).children[0] == MdCodeBlock("a``b```", "") + @pytest.mark.parametrize(("code", "rendered"), [ + # The pad is what the parser's strip removes, so the content's + # own spaces survive it. + (" x ", "` x `"), + (" ", "` `"), + (" x ", "` x `"), + # A single space is below the parser's two-character threshold, + # so it is not stripped and must not be padded either. + (" ", "` `"), + # Backtick padding and space padding are the same one space, not + # two: the content starts with a space, so the space rule fires + # and the backtick rule has nothing left to add. + (" `x` ", "`` `x` ``"), + (" ` ", "`` ` ``"), + # Only one end is a space — the parser strips nothing, so + # neither does the renderer pad. + (" a", "` a`"), + ("a ", "`a `"), + ]) + def test_code_span_pads_content_that_would_be_stripped( + self, code: str, rendered: str, + ) -> None: + """A span whose content starts *and* ends with a space. + + ``_parse_inlines`` strips one leading and one trailing space + whenever the fenced text is two characters or longer and both + ends are spaces. Without a matching pad on the way out that + strip eats the content: ``MdCode(" x ")`` rendered ``` ` x ` ``` + and read back as ``MdCode("x")``. It also collapsed two + distinct values onto one rendering — ``MdCode(" `x` ")`` and + ``MdCode("`x`")`` both produced ``` `` `x` `` ``` — so the loss + was not even recoverable by guessing. + + Only reachable from a *constructed* value, which is why the + round-trip corpus never caught it: the parser strips the spaces + on the way in, so no parse produces the shape that breaks. + """ + doc = MdParagraph((MdCode(code),)) + assert render_markdown(doc) == rendered + + embedded = MdParagraph((MdText("x "), MdCode(code))) + line = render_markdown(embedded) + assert line == "x " + rendered + assert parse_markdown(line).children[0] == embedded + + def test_code_span_padding_distinguishes_two_values(self) -> None: + """The two values that used to share a rendering now differ. + + A parametrized check of each value against its own expected + string would pass even if both strings were equal; asserting + they differ is the property that was actually broken. + """ + spaced = render_markdown(MdParagraph((MdCode(" `x` "),))) + bare = render_markdown(MdParagraph((MdCode("`x`"),))) + assert spaced != bare + + @pytest.mark.parametrize(("doc", "rendered"), [ + # An item with no blocks: marker plus the space the item + # patterns require. A bare "-" is a paragraph. + (MdList(False, ((),)), "- "), + (MdList(False, ((), (MdParagraph((MdText("b"),)),))), "- \n- b"), + (MdList(False, ((MdParagraph((MdText("a"),)),), ())), "- a\n- "), + # The ordered case is the one that corrupts silently: dropping + # the empty item renumbers every item after it. + ( + MdList(True, ( + (MdParagraph((MdText("a"),)),), (), + (MdParagraph((MdText("c"),)),), + )), + "1. a\n2. \n3. c", + ), + ]) + def test_empty_list_item_keeps_its_place( + self, doc: MdList, rendered: str, + ) -> None: + """An item with no blocks is a value the *parser* produces. + + ``- `` reads back as ``MdList(False, ((),))``, so the renderer + owes it a form; dropping it deleted the item outright. The + round trip is asserted as ADT equality, not just as bytes, + because a renderer that emitted a placeholder the parser read as + *something else* would satisfy the weaker form. + """ + assert render_markdown(doc) == rendered + assert parse_markdown(rendered).children[0] == doc + + @pytest.mark.parametrize(("children", "rendered"), [ + ((MdList(False, ()), MdParagraph((MdText("after"),))), "after"), + ((MdParagraph((MdText("before"),)), MdList(False, ())), "before"), + ( + (MdParagraph((MdText("a"),)), MdTable(()), + MdParagraph((MdText("b"),))), + "a\n\nb", + ), + ]) + def test_a_child_that_renders_nothing_takes_no_separator( + self, children: tuple, rendered: str, + ) -> None: + """A zero-line child must not drag a blank line in with it. + + A list with no items and a table with no rows render to nothing. + Counting them anyway left the document separator standing for an + absent block — ``MdDocument([MdList([]), p])`` rendered + ``"\\nafter"`` — which the next parse cannot attribute to + anything, so the render was not a fixed point. That is the + property asserted here, since the leading blank line is + invisible in a bare equality against an expected string. + """ + doc = MdDocument(children) + once = render_markdown(doc) + assert once == rendered + assert render_markdown(parse_markdown(once)) == once + def test_blockquote_children_are_separated(self) -> None: """A blockquote separates its children with a bare ``>``. diff --git a/vera/README.md b/vera/README.md index fe8b2a12..57ac4f80 100644 --- a/vera/README.md +++ b/vera/README.md @@ -115,7 +115,7 @@ execute(compile_result, ...) # → run WASM via wasmtime | ` ├ markdown.py` | 651 | | WASM memory marshalling for MdInline/MdBlock ADTs | | | ` ├ json_serde.py` | 420 | | WASM memory marshalling for Json ADT | | | ` └ html_serde.py` | 261 | | WASM memory marshalling for HtmlNode ADT | | -| `markdown.py` | 651 | Compile | Python Markdown parser/renderer (§9.7.3 subset) | `parse_markdown()`, `render_markdown()`, `has_heading()`, `has_code_block()`, `extract_code_blocks()` | +| `markdown.py` | 728 | Compile | Python Markdown parser/renderer (§9.7.3 subset) | `parse_markdown()`, `render_markdown()`, `has_heading()`, `has_code_block()`, `extract_code_blocks()` | | `obligations/` | 725 | Verify | Reified proof obligations + warm incremental session (#222 A/B) | `ProofObligation`, `VerificationSession` | | ` core.py` | 198 | | ProofObligation record: identity (content_key) + discharge outcome | | | ` cache.py` | 219 | | Invalidation keys (structural/callee/context hashes), DischargeCache | | @@ -752,7 +752,7 @@ The `ERROR_CODES` dict in `errors.py` maps every code to a short description (15 ## Test Suite -Testing spans a **pytest suite** of 10,729 tests across 164 files — compiler-internals unit tests plus a **conformance suite** (216 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. +Testing spans a **pytest suite** of 10,759 tests across 164 files — compiler-internals unit tests plus a **conformance suite** (216 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. See **[TESTING.md](../TESTING.md)** for the comprehensive testing reference -- test file table, conformance suite details, compiler code coverage, language feature coverage, helper conventions, validation scripts, CI pipeline, and guidelines for adding tests. diff --git a/vera/browser/runtime.mjs b/vera/browser/runtime.mjs index 09b38de3..1f3c73ca 100644 --- a/vera/browser/runtime.mjs +++ b/vera/browser/runtime.mjs @@ -802,8 +802,16 @@ function renderInline(node) { if (run > longest) longest = run; } const fence = '`'.repeat(longest + 1); - const pad = (node.text.startsWith('`') || node.text.endsWith('`')) - ? ' ' : ''; + // #1303 review: also pad when the content itself starts AND ends + // with a space. parseInlines strips one such pair whenever the + // fenced text is two characters or longer, so without a pad the + // strip eats the content's own spaces and `MdCode(' x ')` comes + // back as `MdCode('x')` — and `MdCode(' `x` ')` rendered to the + // same bytes as `MdCode('`x`')`. Mirrors _render_code_span. + const stripsOwnSpaces = node.text.length >= 2 + && node.text.startsWith(' ') && node.text.endsWith(' '); + const pad = (node.text.startsWith('`') || node.text.endsWith('`') + || stripsOwnSpaces) ? ' ' : ''; return fence + pad + node.text + pad + fence; } case 'MdEmph': return '*' + node.children.map(renderInline).join('') + '*'; @@ -841,10 +849,14 @@ function renderBlockLines(node) { if (node.children.length === 0) return ['>']; const out = []; node.children.forEach((child, i) => { + const childLines = renderBlockLines(child); + // A child that renders nothing must not leave a bare '>' + // standing for it (#1303 review; mirrors _render_block). + if (childLines.length === 0) return; // A bare '>' between children, mirroring _render_block: without // it a quote holding two paragraphs re-parses as one. - if (i > 0) out.push('>'); - for (const line of renderBlockLines(child)) { + if (i > 0 && out.length > 0) out.push('>'); + for (const line of childLines) { out.push(line ? '> ' + line : '>'); } }); @@ -857,6 +869,16 @@ function renderBlockLines(node) { const indent = ' '.repeat(marker.length + 1); const itemLines = []; for (const child of item) itemLines.push(...renderBlockLines(child)); + if (itemLines.length === 0) { + // #1303 review: an item with no blocks is a value the PARSER + // produces — '- ' reads back as one empty item — so dropping + // it deleted the item and renumbered every ordered item after + // it. The marker plus its space is what reads back; a bare + // '-' is a paragraph, since both item patterns require the + // whitespace. Mirrors _render_block. + out.push(marker + ' '); + return; + } itemLines.forEach((line, j) => { out.push(j === 0 ? marker + ' ' + line : indent + line); }); @@ -877,10 +899,17 @@ function renderBlockLines(node) { } case 'MdDocument': { const out = []; - node.children.forEach((child, i) => { - if (i > 0) out.push(''); - out.push(...renderBlockLines(child)); - }); + for (const child of node.children) { + const childLines = renderBlockLines(child); + // #1303 review: a child that renders to NOTHING — an MdList + // with no items, an MdTable with no rows — must not drag a + // separator in with it, or the blank line survives as a stray + // the next parse cannot attribute to anything and the render + // stops being a fixed point. Mirrors _render_block. + if (childLines.length === 0) continue; + if (out.length > 0) out.push(''); + out.push(...childLines); + } return out; } default: diff --git a/vera/markdown.py b/vera/markdown.py index 40f5ac4d..f775d9ee 100644 --- a/vera/markdown.py +++ b/vera/markdown.py @@ -498,10 +498,20 @@ def _render_block(block: MdBlock) -> list[str]: """Render a single block to lines of Markdown.""" if isinstance(block, MdDocument): result: list[str] = [] - for i, child in enumerate(block.children): - if i > 0: + for child in block.children: + child_lines = _render_block(child) + # #1303 review: a child that renders to NOTHING — an + # `MdList` with no items, an `MdTable` with no rows — must + # not drag a separator in with it. Counting it made the + # separator a stray blank line the next parse cannot + # attribute to anything, so `MdDocument([MdList([]), p])` + # rendered "\nafter" and re-rendered "after": not a fixed + # point, which is the property §9.7.3 asks of the render. + if not child_lines: + continue + if result: result.append("") - result.extend(_render_block(child)) + result.extend(child_lines) return result if isinstance(block, MdParagraph): @@ -534,9 +544,14 @@ def _render_block(block: MdBlock) -> list[str]: # separator is unconditional rather than emitted only where # the next block would otherwise merge: one construct, one # textual representation (§0.2.3). - if i > 0: + child_lines = _render_block(child) + # Same zero-line guard as MdDocument: a child that renders + # nothing must not leave a bare `>` standing for it. + if not child_lines: + continue + if i > 0 and result: result.append(">") - for line in _render_block(child): + for line in child_lines: result.append(f"> {line}" if line else ">") return result @@ -547,6 +562,17 @@ def _render_block(block: MdBlock) -> list[str]: item_lines: list[str] = [] for child in item: item_lines.extend(_render_block(child)) + if not item_lines: + # #1303 review: an item with no blocks is a value the + # PARSER produces — `- ` reads back as `MdList([()])` — + # so the renderer owed it a form. Dropping it deleted + # the item outright, and in a multi-item list silently + # renumbered everything after it. The marker plus its + # space is what the parser reads back: a bare `-` is a + # paragraph, because both item patterns require the + # whitespace. + result.append(f"{marker} ") + continue for j, line in enumerate(item_lines): if j == 0: result.append(f"{marker} {line}") @@ -584,10 +610,22 @@ def _render_code_span(code: str) -> str: The fence is one backtick longer than the longest run *inside* the content, because `_parse_inlines` closes a span on the first run of equal length — a fixed two-backtick fence therefore terminates on - the content's own ``` `` ``` and loses the rest. Padding spaces are - added only when the content starts or ends with a backtick, where - they are what keeps the fence and the content from merging into one - longer run; the parser strips exactly one such pair. + the content's own ``` `` ``` and loses the rest. + + Padding spaces are added in exactly the two cases where the parser + would otherwise not read the content back. `_parse_inlines` strips + one leading and one trailing space iff the fenced text is at least + two characters long and both ends are spaces, so the renderer pads + when: + + * the content starts or ends with a backtick — the pad is what keeps + the fence and the content from merging into one longer run; and + * the content itself starts and ends with a space (#1303 review) — + without a pad the parser's unconditional strip eats the content's + own spaces, so ``MdCode(" x ")`` came back as ``MdCode("x")``. + With it the strip removes the pad instead and the content + survives, which also separates ``MdCode(" `x` ")`` from + ``MdCode("`x`")``: both used to render to the same bytes. """ longest = 0 run = 0 @@ -595,7 +633,14 @@ def _render_code_span(code: str) -> str: run = run + 1 if ch == "`" else 0 longest = max(longest, run) fence = "`" * (longest + 1) - pad = " " if code.startswith("`") or code.endswith("`") else "" + strips_own_spaces = ( + len(code) >= 2 and code[0] == " " and code[-1] == " " + ) + pad = ( + " " + if code.startswith("`") or code.endswith("`") or strips_own_spaces + else "" + ) return f"{fence}{pad}{code}{pad}{fence}" From 6ec14d1e0c5b8fa025fba185df46843497fd0abe Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 04:21:01 +0100 Subject: [PATCH 14/69] Say what the parity tests do now, in the FAQ's closing line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser section closed on "except for the two divergences above, where each runtime's exact output is pinned separately", which describes an arrangement that no longer exists and contradicts the paragraph three lines above it. Neither half survives: json_stringify and md_render are single-truth parity assertions, so there is no per-host pin to point at, and md_parse is not pinned per host at all — the suite carries the shapes the two implementations agree on and the rest is a tracked bug. The replacement says what the batteries actually assert, including why they assert the expected string and not only cross-host equality: two hosts agreeing on a wrong answer would satisfy equality by itself. Swept README.md, SKILL.md, TESTING.md and the spec chapters for other survivors of the phrasing; the one other hit is "mutation-pinned separately" in an unrelated TESTING.md row. Skip-changelog: documentation wording only, of behaviour the [Unreleased] bullets already describe. Co-Authored-By: Claude --- FAQ.md | 2 +- docs/llms-full.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/FAQ.md b/FAQ.md index df340d1d..e4090ab6 100644 --- a/FAQ.md +++ b/FAQ.md @@ -180,7 +180,7 @@ The runtime also works in Node.js: node --experimental-wasm-exnref vera/browser/harness.mjs module.wasm ``` -Mandatory parity tests enforce that on every PR — except for the two divergences above, where each runtime's exact output is pinned separately so a fix goes red rather than passing unnoticed. +Mandatory parity tests enforce that on every PR. For the two operations that carry a canonical form, each case asserts the expected string as well as cross-host equality, since two hosts agreeing on a wrong answer would satisfy equality on its own; for `md_parse` the suite covers the shapes the two implementations do agree on, so a regression on one of those goes red. ## How does contract-driven testing work? diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 669fb2d4..d66a27fb 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -3157,7 +3157,7 @@ The runtime also works in Node.js: node --experimental-wasm-exnref vera/browser/harness.mjs module.wasm ``` -Mandatory parity tests enforce that on every PR — except for the two divergences above, where each runtime's exact output is pinned separately so a fix goes red rather than passing unnoticed. +Mandatory parity tests enforce that on every PR. For the two operations that carry a canonical form, each case asserts the expected string as well as cross-host equality, since two hosts agreeing on a wrong answer would satisfy equality on its own; for `md_parse` the suite covers the shapes the two implementations do agree on, so a regression on one of those goes red. ## How does contract-driven testing work? From b95524aead1dc7ce097d7ce1a7afa7930044b5e9 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 04:24:57 +0100 Subject: [PATCH 15/69] Table the lone-surrogate divergence the reachability probe found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Looking for whether the canonical JSON serializers could ever be handed an unpaired surrogate turned up a divergence one layer earlier, on the way IN rather than out. A JSON `\uD800` escape decodes to a string no well-formed UTF-8 can hold, and the two hosts part company writing it into WASM memory: TextEncoder substitutes U+FFFD and returns, while _alloc_string's .encode("utf-8") raises. The program prints a quietly different string in the browser and dies with a Python traceback natively, from Vera source that is plain ASCII. The row records what the probe settled about the serializers too, since that is the question someone will ask next: dumps_canonical does emit a raw surrogate where the browser's walk escapes it, but the string boundary eliminates surrogates before either walk can see one, so that divergence is unreachable and this one is not. Filed as #1308, and cross-linked both ways — to #1306, which asks the same question about json_parse's domain from the NaN side, and to #1302, whose raw-traceback path is what the native refusal surfaces through. Co-Authored-By: Claude --- KNOWN_ISSUES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index cc151017..0326433b 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -8,6 +8,7 @@ Defects in shipped compiler, runtime, or tooling behaviour — this table matche | Bug | Issue | |-----|-------| +| `json_parse` of a lone-surrogate escape substitutes U+FFFD in the browser and crashes the reference host. `json_parse("{\"k\":\"a\\ud800b\"}")` — whose Vera source is plain ASCII, so nothing refuses it earlier — prints `{"k":"a\ufffdb"}` in the browser and kills `vera run` with a `UnicodeEncodeError` traceback natively. Both hosts decode a JSON `\uD800` escape into a string holding an unpaired surrogate, which has no well-formed UTF-8 encoding, and they then part company writing it into WASM memory: `TextEncoder` replaces it with U+FFFD and returns, while `_alloc_string`'s `.encode("utf-8")` raises. Neither host is obviously right — one silently swaps a different valid string for the requested one, the other refuses but through the raw-traceback path [#1302](https://github.com/aallan/vera/issues/1302) tracks. Sibling of [#1306](https://github.com/aallan/vera/issues/1306): both are the same question about `json_parse`'s domain — which inputs it accepts, and which call refuses the ones it should not. The canonical serializers are NOT implicated: `dumps_canonical` emits a raw surrogate where the browser's walk escapes it, but the string boundary eliminates surrogates before either can see one, so the divergence is unreachable from there. Fix direction: rule on whether a Vera `String` is UTF-8 by construction — if it is, `json_parse` refuses the escape on both hosts and says so; if it is not, both hosts need one agreed lossy or WTF-8 representation. | [#1308](https://github.com/aallan/vera/issues/1308) | | `json_parse` accepts `NaN`, `Infinity` and `-Infinity` on the reference runtime and refuses them in the browser. The reference host calls `json.loads`, whose default `parse_constant` admits all three; the browser host calls `JSON.parse`, which refuses them as RFC 8259 requires. Measured through identical module bytes under both runtimes, `NaN`, `Infinity`, `-Infinity` and a nested `[NaN]` each take the `Ok` arm natively and the `Err` arm in the browser — a §12.9.3 divergence on the shared surface, and one no test covered because every JSON input in the parity battery is RFC-conformant. The hosts also disagree about *which call* refuses: the browser returns a handled `Err` from the parse, while natively the value parses and the following `json_stringify` raises the non-finite refusal [#1293](https://github.com/aallan/vera/issues/1293) installed, which [#1302](https://github.com/aallan/vera/issues/1302)'s host-callback path then presents as a Python traceback. Fix direction: hand `json.loads` a `parse_constant` that rejects, so the reference host refuses at the point RFC 8259 and the browser already do. | [#1306](https://github.com/aallan/vera/issues/1306) | | `json_keys`' result cannot be used from compiled Vera. The `Some` arm of its `Option>` binds a match binder of type `Array`, and codegen emits that binder's local as `(local $lN i32_pair)` — `i32_pair` being the internal String `(ptr, len)` spelling rather than a WASM value type — so the module fails WAT assembly with `unexpected token, expected one of: i32, i64, …`. A ten-line `public fn key_count(@Json -> @Int)` matching `json_keys` and calling `array_length` on the binder passes `vera check` and fails `vera compile`; `string_join` on the same binder fails identically. Loud rather than wrong, and narrow: `array_length(map_keys(...))` compiles, so `Array` alone is fine, and `json_get_array`'s `Some(@Array)` binder compiles, so a binder alone is fine — it is the pair that breaks. Fix direction: give the binder's local the array's own value type instead of its element's. | [#1305](https://github.com/aallan/vera/issues/1305) | | A non-finite `JNumber` reaching `json_stringify` fails with the right sentence and the wrong presentation: `vera run` emits a **61-line, 3,214-character Python traceback** — 15 stack frames, 7 of them inside `.venv/`, and **no mention of the user's `.vera` file at all** — with the actual message (`json_stringify: NaN is not representable in JSON — RFC 8259 has no NaN or Infinity. …`) as its last line. A contract violation in the same program shape produces **5 lines**: the failed clause and a source backtrace with `.vera` line numbers. The buffered stdout is discarded on the way out rather than surfaced, so the `#522` output-preservation contract is lost too. The invariant this breaks is already written down, in a comment on `host_print` in `vera/codegen/api.py`: "A user-level program must never produce a Python traceback regardless of what it does — the `WasmTrapError` contract from #516/#522/#547 holds here too." Root cause: `execute()` converts an exception into `WasmTrapError` only when `type(exc).__name__` is `Trap` or `WasmtimeError`, and a `ValueError` raised inside a host callback arrives at that handler as a `ValueError`, so the branch is skipped — no classification, no source-map frame resolution, no captured output. `json_stringify` is the case that surfaced it ([#1293](https://github.com/aallan/vera/issues/1293)), but the gap is generic: any host callback raising a non-`Trap` Python exception takes the same path. Fix direction: give host-callback failures a Vera-native shape — a dedicated error type classified alongside the traps, or widen the conversion to any exception escaping a host import — carrying the captured stdout and a source-mapped backtrace either way. | [#1302](https://github.com/aallan/vera/issues/1302) | From 95f715f4bcab1335b08f6f6f6c8b028b3c3d6cb8 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 04:45:15 +0100 Subject: [PATCH 16/69] State the Markdown rules at the strength the tests enforce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four documentation corrections from the review, all in the Markdown and JSON parity prose the last two commits moved. §9.7.3's two normative sentences were lowercase "should", which says nothing under RFC 2119. Checking the strength before uppercasing them turned one into a correction rather than a keyword swap. The fixed point is unconditional — no case in the 6,078-entry parity corpus fails it on either runtime, and a block with no text renders to no lines, which re-renders to no lines — so it reads MUST with no carve-out. The round-trip property is not: its exception clause named the two unwriteable code spans and stopped there, but an MdList with no items and an MdTable with no rows also break it, because they render to no lines and so re-parse to no block. Naming both families is what makes MUST honest, and the empty MdBlockQuote and the empty list ITEM are called out as being inside the property, since each now has a form. §9.7.2's "must satisfy |exp| <= 999999" is uppercased in the same pass; it is the only other lowercase normative in §9.7.x, and the remaining "may not be exact" is descriptive, not a permission. The rules had no executable form, which is a gap the section could afford less than most: every one of them is reachable only from a value a program builds, so a reader has nowhere to look for what they produce. Two compiled, contract-verified examples now stand after the exception paragraph — one for the container rules, one for the code-span and empty-item rules — with the bytes each renders written out beside it. KNOWN_ISSUES' #1308 row showed the browser printing the six characters of a � escape. It prints the character; the canonical form emits non-ASCII literally (§9.7.1), so the escape misreported the very output the row is about. The \uD800 escapes elsewhere in the row are correct as written — the JSON input text really does contain those characters. The FAQ said State, contracts, JSON and Markdown rendering "work identically", which the two parsers have not for some time. It now separates the operations that carry a canonical form from the parsers that do not, names json_parse's two tracked differences beside md_parse's, and says plainly that well-formed JSON parses the same on both. The section's closing line is brought into agreement rather than left describing md_parse alone. Co-Authored-By: Claude --- CHANGELOG.md | 2 +- FAQ.md | 4 ++-- KNOWN_ISSUES.md | 2 +- docs/llms-full.txt | 4 ++-- spec/09-standard-library.md | 44 ++++++++++++++++++++++++++++++++++--- 5 files changed, 47 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07a11256..751e258b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **`new(State)` reads the cell its contract names** ([#1285](https://github.com/aallan/vera/issues/1285)). `old(State)` has been keyed on the resolved cell family since [#1205](https://github.com/aallan/vera/issues/1205)/[#1209](https://github.com/aallan/vera/issues/1209); `new(State)` read the name-keyed `_effect_ops["get"]`, which holds whichever `State` the effect row registered first. Under a single-`State` row the two keyings coincide, which is why the corpus agreed; under a multi-`State` row the two sides of one `ensures` clause read different cells. `effects(, State>)` with `ensures(new(State) == false)` was check-green *and* verify-green, emitted `state_get_Int`'s i64 into the Bool comparison's `i32.eq`, and died at load with wasmtime's raw `type mismatch: expected i32, found i64`. The type mismatch is the symptom rather than the defect: where both cells share a machine width — `State` beside `State` — the module loaded and answered about the other cell, so `ensures(new(State) == old(State))` on a function that writes neither was refuted at runtime on a contract the verifier had discharged. Codegen now carries a family→getter registry populated at the declared-row registration site from the per-family `CellNames` it already computes, and `_translate_new_expr` keys on `_state_effect_family` exactly as `_translate_old_expr` does. A bare `get(())` names no family and so is right to keep reading the name-keyed registry, source-order-first-wins; a contract names one and must not. -- **Three constructed `MdBlock` values the renderer could not write back** ([#1294](https://github.com/aallan/vera/issues/1294) review). All three are reachable only from a value a program *builds*, which is why a round-trip corpus could not find them: the parser never produces the shape that breaks. **A code span whose content starts and ends with a space** was eaten by the parser's own strip — `_parse_inlines` removes one such pair whenever the fenced text is two characters or longer, so `MdCode(" x ")` rendered `` ` x ` `` and read back as `MdCode("x")`, and `MdCode(" `x` ")` rendered to the same bytes as ``MdCode("`x`")``, which made the loss unrecoverable even by guessing. The renderer now pads those spans the same way it already padded backtick-bounded ones, so the strip removes the pad instead of the content; spec §9.7.3 loses one of its three documented round-trip losses as a result. **A list item with no blocks** was dropped outright, though `- ` is exactly what the parser reads back as one — and in an ordered list dropping it silently renumbered every item after it. It now renders as its marker plus the space both item patterns require (a bare `-` is a paragraph). **A container that renders to nothing** — a list with no items, a table with no rows — still drew the document's blank-line separator, so `MdDocument([MdList([]), p])` rendered `"\nafter"`: a blank line standing for an absent block, which the next parse cannot attribute to anything and which cost the render its fixed-point property. A zero-line child now contributes no separator, in `MdBlockQuote` as well as `MdDocument`. Both runtimes move together and the two spec rules are restated; the change is held to a zero-regression bar over the 6,078-case Markdown corpus — ADT agreement, render agreement and each host's fixed-point property all show no ok→broken transition. +- **Three constructed `MdBlock` values the renderer could not write back** ([#1294](https://github.com/aallan/vera/issues/1294) review). All three are reachable only from a value a program *builds*, which is why a round-trip corpus could not find them: the parser never produces the shape that breaks. **A code span whose content starts and ends with a space** was eaten by the parser's own strip — `_parse_inlines` removes one such pair whenever the fenced text is two characters or longer, so `MdCode(" x ")` rendered `` ` x ` `` and read back as `MdCode("x")`, and `MdCode(" `x` ")` rendered to the same bytes as ``MdCode("`x`")``, which made the loss unrecoverable even by guessing. The renderer now pads those spans the same way it already padded backtick-bounded ones, so the strip removes the pad instead of the content; spec §9.7.3 loses one of its three documented round-trip losses as a result. **A list item with no blocks** was dropped outright, though `- ` is exactly what the parser reads back as one — and in an ordered list dropping it silently renumbered every item after it. It now renders as its marker plus the space both item patterns require (a bare `-` is a paragraph). **A container that renders to nothing** — a list with no items, a table with no rows — still drew the document's blank-line separator, so `MdDocument([MdList([]), p])` rendered `"\nafter"`: a blank line standing for an absent block, which the next parse cannot attribute to anything and which cost the render its fixed-point property. A zero-line child now contributes no separator, in `MdBlockQuote` as well as `MdDocument`. Both runtimes move together and the two spec rules are restated; the change is held to a zero-regression bar over the 6,078-case Markdown corpus — ADT agreement, render agreement and each host's fixed-point property all show no ok→broken transition. §9.7.3's two normative sentences are stated at the strength the batteries enforce while the section is open: the fixed point is unconditional, so it reads MUST with no carve-out, and the round-trip property reads MUST with an exception clause that now names *both* families outside it — the two unwriteable code spans, and a container with nothing in it to write, since an `MdList` with no items renders to no lines and so re-parses to no block. The earlier clause named only the code spans, which made the property false for the empty containers it did not mention. Each rule cluster also gains a compiled, contract-verified example, because every one of them is reachable only from a constructed value and the section had no executable form of that. - **A block quote separates and keeps its children, on both runtimes** ([#1294](https://github.com/aallan/vera/issues/1294) review). The **reference** renderer emitted no separator between a blockquote's children, so `MdBlockQuote([Para, Para])` — the shape `md_parse` builds from `> a\n>\n> b` — rendered as two adjacent quoted lines and read back as **one** paragraph. Structure lost silently, on both hosts, and the round-trip property spec §9.7.3 states did not hold for it. A quote with no children was the same defect one size down: it rendered as no lines at all, so a `>` in a document vanished on the round trip and left the enclosing separator dangling (`---\n>` came back as `---`). Both arms now mirror `MdDocument`'s: a bare `>` between children, and `>` for an empty quote. Two more mirrors land with them. The **browser parser**'s blockquote reader required `> ` with the space where the reference accepts `^>\s?`, and had no lazy-continuation branch at all, so `>no space` parsed as literal text and `> a\nb` pushed the second line out of the quote — both inside #1294's stated scope and neither closed by the renderer fix. And a **code span** is now fenced with one backtick more than its longest internal run rather than a fixed two, padded only when the content starts or ends with a backtick: the old rule was right for one backtick and wrong for two, since ``` `` a``b `` ``` closes on the run *inside* the content. The browser's inline parser scanned for the next single backtick rather than a run of equal length, so it could not read that back either; it now counts runs like the reference. Spec §9.7.3 states all four rules. Measured on a 1,471-input adversarial corpus: `md_render` is a fixed point on **every** input on both hosts (from 65 and 54 unstable), and cross-host render divergence falls from 489 inputs to 34; on 4,850 sections of the project's own documentation, render divergence falls from 40 to 11 and the reference round trip is a fixed point everywhere. The remaining `md_parse` divergences are a separate tracked bug, listed in `KNOWN_ISSUES.md` with a measured class-by-class breakdown. Alongside them: the browser's `json_stringify` no longer falls back to `"null"` when `JSON.stringify` returns `undefined` — unreachable from `readJson`, but the same silent substitution [#1293](https://github.com/aallan/vera/issues/1293) removed one layer up, and an unreachable branch is where a silent wrong answer survives; and the non-finite parity test now asserts the *whole* shared sentence, taken from the reference implementation so the browser's hand-copied duplicate is held against the original, plus that neither host printed anything before failing. diff --git a/FAQ.md b/FAQ.md index e4090ab6..a94f5c7f 100644 --- a/FAQ.md +++ b/FAQ.md @@ -170,7 +170,7 @@ vera compile --target browser examples/hello_world.vera # index.html ``` -Serve it with any HTTP server and open `index.html` — no build step, no bundler, no dependencies. The JavaScript runtime provides browser-appropriate implementations of all Vera host bindings: `IO.print` writes to the page, `IO.read_line` uses `prompt()`, and all other operations (State, contracts, JSON, Markdown rendering) work identically to the wasmtime runtime. `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states — §9.7.1 and §9.7.3 — rather than by the two hosts happening to agree, which is what the parity suite checks them against. `md_parse` is the exception: the two hand-written parsers disagree across nine measured classes of input the §9.7.3 subset leaves open, the largest by a wide margin being how a paragraph's plain-text runs are grouped — invisible to `md_render`, since the runs concatenate to the same text — and the rest render-visible, from how emphasis markers are scanned to block markers such as a `+` bullet or a list nested more than two deep. That is a tracked bug ([#1301](https://github.com/aallan/vera/issues/1301)). +Serve it with any HTTP server and open `index.html` — no build step, no bundler, no dependencies. The JavaScript runtime provides browser-appropriate implementations of all Vera host bindings: `IO.print` writes to the page, `IO.read_line` uses `prompt()`, and State, contracts, JSON serialization and Markdown rendering work identically to the wasmtime runtime. `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states — §9.7.1 and §9.7.3 — rather than by the two hosts happening to agree, which is what the parity suite checks them against. The two *parsers* are where the runtimes still differ, and each difference is tracked. `json_parse` disagrees on inputs outside RFC 8259: the reference host accepts `NaN` and the infinities where the browser refuses them ([#1306](https://github.com/aallan/vera/issues/1306)), and a `\uD800` escape becomes U+FFFD in the browser where the reference host fails outright ([#1308](https://github.com/aallan/vera/issues/1308)) — well-formed JSON parses the same on both. `md_parse` differs more widely: the two hand-written parsers disagree across nine measured classes of input the §9.7.3 subset leaves open, the largest by a wide margin being how a paragraph's plain-text runs are grouped — invisible to `md_render`, since the runs concatenate to the same text — and the rest render-visible, from how emphasis markers are scanned to block markers such as a `+` bullet or a list nested more than two deep. That one is tracked as [#1301](https://github.com/aallan/vera/issues/1301). Two effects are refused outright rather than merely differing. `Inference` and `DB` return an explanatory `Err` from every operation in the browser, because the API key or database credential they would need is readable from page source and network traffic in client-side JavaScript. Reach them through a server-side endpoint and call it with `Http`, which does run in the browser — it is backed by `XMLHttpRequest`, not a stub. That refusal is a deliberate platform boundary; spec §9.5.5 states it for `Inference`. @@ -180,7 +180,7 @@ The runtime also works in Node.js: node --experimental-wasm-exnref vera/browser/harness.mjs module.wasm ``` -Mandatory parity tests enforce that on every PR. For the two operations that carry a canonical form, each case asserts the expected string as well as cross-host equality, since two hosts agreeing on a wrong answer would satisfy equality on its own; for `md_parse` the suite covers the shapes the two implementations do agree on, so a regression on one of those goes red. +Mandatory parity tests enforce that on every PR. For the two operations that carry a canonical form, each case asserts the expected string as well as cross-host equality, since two hosts agreeing on a wrong answer would satisfy equality on its own; for the two parsers it covers the inputs the implementations do agree on — every well-formed JSON document, and the Markdown shapes outside [#1301](https://github.com/aallan/vera/issues/1301)'s nine classes — so a regression on one of those goes red. ## How does contract-driven testing work? diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 0326433b..e33dd002 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -8,7 +8,7 @@ Defects in shipped compiler, runtime, or tooling behaviour — this table matche | Bug | Issue | |-----|-------| -| `json_parse` of a lone-surrogate escape substitutes U+FFFD in the browser and crashes the reference host. `json_parse("{\"k\":\"a\\ud800b\"}")` — whose Vera source is plain ASCII, so nothing refuses it earlier — prints `{"k":"a\ufffdb"}` in the browser and kills `vera run` with a `UnicodeEncodeError` traceback natively. Both hosts decode a JSON `\uD800` escape into a string holding an unpaired surrogate, which has no well-formed UTF-8 encoding, and they then part company writing it into WASM memory: `TextEncoder` replaces it with U+FFFD and returns, while `_alloc_string`'s `.encode("utf-8")` raises. Neither host is obviously right — one silently swaps a different valid string for the requested one, the other refuses but through the raw-traceback path [#1302](https://github.com/aallan/vera/issues/1302) tracks. Sibling of [#1306](https://github.com/aallan/vera/issues/1306): both are the same question about `json_parse`'s domain — which inputs it accepts, and which call refuses the ones it should not. The canonical serializers are NOT implicated: `dumps_canonical` emits a raw surrogate where the browser's walk escapes it, but the string boundary eliminates surrogates before either can see one, so the divergence is unreachable from there. Fix direction: rule on whether a Vera `String` is UTF-8 by construction — if it is, `json_parse` refuses the escape on both hosts and says so; if it is not, both hosts need one agreed lossy or WTF-8 representation. | [#1308](https://github.com/aallan/vera/issues/1308) | +| `json_parse` of a lone-surrogate escape substitutes U+FFFD in the browser and crashes the reference host. `json_parse("{\"k\":\"a\\ud800b\"}")` — whose Vera source is plain ASCII, so nothing refuses it earlier — prints `{"k":"a�b"}` in the browser and kills `vera run` with a `UnicodeEncodeError` traceback natively. Both hosts decode a JSON `\uD800` escape into a string holding an unpaired surrogate, which has no well-formed UTF-8 encoding, and they then part company writing it into WASM memory: `TextEncoder` replaces it with U+FFFD and returns, while `_alloc_string`'s `.encode("utf-8")` raises. Neither host is obviously right — one silently swaps a different valid string for the requested one, the other refuses but through the raw-traceback path [#1302](https://github.com/aallan/vera/issues/1302) tracks. Sibling of [#1306](https://github.com/aallan/vera/issues/1306): both are the same question about `json_parse`'s domain — which inputs it accepts, and which call refuses the ones it should not. The canonical serializers are NOT implicated: `dumps_canonical` emits a raw surrogate where the browser's walk escapes it, but the string boundary eliminates surrogates before either can see one, so the divergence is unreachable from there. Fix direction: rule on whether a Vera `String` is UTF-8 by construction — if it is, `json_parse` refuses the escape on both hosts and says so; if it is not, both hosts need one agreed lossy or WTF-8 representation. | [#1308](https://github.com/aallan/vera/issues/1308) | | `json_parse` accepts `NaN`, `Infinity` and `-Infinity` on the reference runtime and refuses them in the browser. The reference host calls `json.loads`, whose default `parse_constant` admits all three; the browser host calls `JSON.parse`, which refuses them as RFC 8259 requires. Measured through identical module bytes under both runtimes, `NaN`, `Infinity`, `-Infinity` and a nested `[NaN]` each take the `Ok` arm natively and the `Err` arm in the browser — a §12.9.3 divergence on the shared surface, and one no test covered because every JSON input in the parity battery is RFC-conformant. The hosts also disagree about *which call* refuses: the browser returns a handled `Err` from the parse, while natively the value parses and the following `json_stringify` raises the non-finite refusal [#1293](https://github.com/aallan/vera/issues/1293) installed, which [#1302](https://github.com/aallan/vera/issues/1302)'s host-callback path then presents as a Python traceback. Fix direction: hand `json.loads` a `parse_constant` that rejects, so the reference host refuses at the point RFC 8259 and the browser already do. | [#1306](https://github.com/aallan/vera/issues/1306) | | `json_keys`' result cannot be used from compiled Vera. The `Some` arm of its `Option>` binds a match binder of type `Array`, and codegen emits that binder's local as `(local $lN i32_pair)` — `i32_pair` being the internal String `(ptr, len)` spelling rather than a WASM value type — so the module fails WAT assembly with `unexpected token, expected one of: i32, i64, …`. A ten-line `public fn key_count(@Json -> @Int)` matching `json_keys` and calling `array_length` on the binder passes `vera check` and fails `vera compile`; `string_join` on the same binder fails identically. Loud rather than wrong, and narrow: `array_length(map_keys(...))` compiles, so `Array` alone is fine, and `json_get_array`'s `Some(@Array)` binder compiles, so a binder alone is fine — it is the pair that breaks. Fix direction: give the binder's local the array's own value type instead of its element's. | [#1305](https://github.com/aallan/vera/issues/1305) | | A non-finite `JNumber` reaching `json_stringify` fails with the right sentence and the wrong presentation: `vera run` emits a **61-line, 3,214-character Python traceback** — 15 stack frames, 7 of them inside `.venv/`, and **no mention of the user's `.vera` file at all** — with the actual message (`json_stringify: NaN is not representable in JSON — RFC 8259 has no NaN or Infinity. …`) as its last line. A contract violation in the same program shape produces **5 lines**: the failed clause and a source backtrace with `.vera` line numbers. The buffered stdout is discarded on the way out rather than surfaced, so the `#522` output-preservation contract is lost too. The invariant this breaks is already written down, in a comment on `host_print` in `vera/codegen/api.py`: "A user-level program must never produce a Python traceback regardless of what it does — the `WasmTrapError` contract from #516/#522/#547 holds here too." Root cause: `execute()` converts an exception into `WasmTrapError` only when `type(exc).__name__` is `Trap` or `WasmtimeError`, and a `ValueError` raised inside a host callback arrives at that handler as a `ValueError`, so the branch is skipped — no classification, no source-map frame resolution, no captured output. `json_stringify` is the case that surfaced it ([#1293](https://github.com/aallan/vera/issues/1293)), but the gap is generic: any host callback raising a non-`Trap` Python exception takes the same path. Fix direction: give host-callback failures a Vera-native shape — a dedicated error type classified alongside the traps, or widen the conversion to any exception escaping a host import — carrying the captured stdout and a source-mapped backtrace either way. | [#1302](https://github.com/aallan/vera/issues/1302) | diff --git a/docs/llms-full.txt b/docs/llms-full.txt index d66a27fb..bc7ae9d9 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -3147,7 +3147,7 @@ vera compile --target browser examples/hello_world.vera # index.html ``` -Serve it with any HTTP server and open `index.html` — no build step, no bundler, no dependencies. The JavaScript runtime provides browser-appropriate implementations of all Vera host bindings: `IO.print` writes to the page, `IO.read_line` uses `prompt()`, and all other operations (State, contracts, JSON, Markdown rendering) work identically to the wasmtime runtime. `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states — §9.7.1 and §9.7.3 — rather than by the two hosts happening to agree, which is what the parity suite checks them against. `md_parse` is the exception: the two hand-written parsers disagree across nine measured classes of input the §9.7.3 subset leaves open, the largest by a wide margin being how a paragraph's plain-text runs are grouped — invisible to `md_render`, since the runs concatenate to the same text — and the rest render-visible, from how emphasis markers are scanned to block markers such as a `+` bullet or a list nested more than two deep. That is a tracked bug ([#1301](https://github.com/aallan/vera/issues/1301)). +Serve it with any HTTP server and open `index.html` — no build step, no bundler, no dependencies. The JavaScript runtime provides browser-appropriate implementations of all Vera host bindings: `IO.print` writes to the page, `IO.read_line` uses `prompt()`, and State, contracts, JSON serialization and Markdown rendering work identically to the wasmtime runtime. `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states — §9.7.1 and §9.7.3 — rather than by the two hosts happening to agree, which is what the parity suite checks them against. The two *parsers* are where the runtimes still differ, and each difference is tracked. `json_parse` disagrees on inputs outside RFC 8259: the reference host accepts `NaN` and the infinities where the browser refuses them ([#1306](https://github.com/aallan/vera/issues/1306)), and a `\uD800` escape becomes U+FFFD in the browser where the reference host fails outright ([#1308](https://github.com/aallan/vera/issues/1308)) — well-formed JSON parses the same on both. `md_parse` differs more widely: the two hand-written parsers disagree across nine measured classes of input the §9.7.3 subset leaves open, the largest by a wide margin being how a paragraph's plain-text runs are grouped — invisible to `md_render`, since the runs concatenate to the same text — and the rest render-visible, from how emphasis markers are scanned to block markers such as a `+` bullet or a list nested more than two deep. That one is tracked as [#1301](https://github.com/aallan/vera/issues/1301). Two effects are refused outright rather than merely differing. `Inference` and `DB` return an explanatory `Err` from every operation in the browser, because the API key or database credential they would need is readable from page source and network traffic in client-side JavaScript. Reach them through a server-side endpoint and call it with `Http`, which does run in the browser — it is backed by `XMLHttpRequest`, not a stub. That refusal is a deliberate platform boundary; spec §9.5.5 states it for `Inference`. @@ -3157,7 +3157,7 @@ The runtime also works in Node.js: node --experimental-wasm-exnref vera/browser/harness.mjs module.wasm ``` -Mandatory parity tests enforce that on every PR. For the two operations that carry a canonical form, each case asserts the expected string as well as cross-host equality, since two hosts agreeing on a wrong answer would satisfy equality on its own; for `md_parse` the suite covers the shapes the two implementations do agree on, so a regression on one of those goes red. +Mandatory parity tests enforce that on every PR. For the two operations that carry a canonical form, each case asserts the expected string as well as cross-host equality, since two hosts agreeing on a wrong answer would satisfy equality on its own; for the two parsers it covers the inputs the implementations do agree on — every well-formed JSON document, and the Markdown shapes outside [#1301](https://github.com/aallan/vera/issues/1301)'s nine classes — so a regression on one of those goes red. ## How does contract-driven testing work? diff --git a/spec/09-standard-library.md b/spec/09-standard-library.md index e760ddd1..f5a9e48d 100644 --- a/spec/09-standard-library.md +++ b/spec/09-standard-library.md @@ -2155,7 +2155,7 @@ Decimal is an opaque built-in type implemented via host imports, following the s | `decimal_to_string(d)` | `(Decimal) → String` | String representation | | `decimal_to_float(d)` | `(Decimal) → Float64` | Potentially lossy conversion to float | -**`decimal_from_string` grammar:** both runtimes accept exactly the language `[+-]? ( digits ( "." digits? )? | "." digits ) ( ("e" | "E") [+-]? digits )?` where `digits` is one or more ASCII `0`–`9`, applied after ignoring surrounding whitespace, and the exponent token (when present) must satisfy `|exp| <= 999999` — the default context's exponent floor, cited by the `decimal_round` fallback below and chosen to keep operand magnitudes bounded and the exponent-token check exact. Only finite decimals are accepted: special values (`NaN`, `sNaN`, `Infinity`), digit-group underscores (`1_000`), non-ASCII digits, and out-of-range exponent tokens are all rejected with `None`, even where a host decimal library would accept them. The accepted domain is defined by this grammar rather than inherited from whatever the host library parses (DESIGN.md: explicit over implicit) — the Python host pre-validates with this grammar before constructing a `decimal.Decimal`, and the browser runtime's parser recognises the same language, checking the exponent token as a string before any numeric conversion (an unbounded token would otherwise round silently above 2^53). This `|exp| <= 999999` bound constrains **input literals** only; exact arithmetic on accepted operands can grow the exponent past it — `decimal_mul(decimal_from_string("1e999999"), decimal_from_string("1e999999"))` yields `1E+1999998` — and such results are computed and rendered identically in both runtimes (the Python host runs the binary operations in a context whose exponent range is widened to the library maximum, `±10^18`, so a finite result never overflows and matches the browser's unbounded engine). +**`decimal_from_string` grammar:** both runtimes accept exactly the language `[+-]? ( digits ( "." digits? )? | "." digits ) ( ("e" | "E") [+-]? digits )?` where `digits` is one or more ASCII `0`–`9`, applied after ignoring surrounding whitespace, and the exponent token (when present) MUST satisfy `|exp| <= 999999` — the default context's exponent floor, cited by the `decimal_round` fallback below and chosen to keep operand magnitudes bounded and the exponent-token check exact. Only finite decimals are accepted: special values (`NaN`, `sNaN`, `Infinity`), digit-group underscores (`1_000`), non-ASCII digits, and out-of-range exponent tokens are all rejected with `None`, even where a host decimal library would accept them. The accepted domain is defined by this grammar rather than inherited from whatever the host library parses (DESIGN.md: explicit over implicit) — the Python host pre-validates with this grammar before constructing a `decimal.Decimal`, and the browser runtime's parser recognises the same language, checking the exponent token as a string before any numeric conversion (an unbounded token would otherwise round silently above 2^53). This `|exp| <= 999999` bound constrains **input literals** only; exact arithmetic on accepted operands can grow the exponent past it — `decimal_mul(decimal_from_string("1e999999"), decimal_from_string("1e999999"))` yields `1E+1999998` — and such results are computed and rendered identically in both runtimes (the Python host runs the binary operations in a context whose exponent range is widened to the library maximum, `±10^18`, so a finite result never overflows and matches the browser's unbounded engine). **Arithmetic:** @@ -2267,7 +2267,7 @@ public fn md_render(@MdBlock -> @String) effects(pure) ``` -Renders an `MdBlock` to a canonical Markdown string. Always succeeds. For every block the subset can write back — all but the two code-span shapes named below — the round-trip property `md_parse(md_render(b)) == Ok(b)` should hold: rendering then re-parsing preserves structure. Every runtime produces the same string (§12.9.3). +Renders an `MdBlock` to a canonical Markdown string. Always succeeds. For every block the subset can write back, the round-trip property `md_parse(md_render(b)) == Ok(b)` MUST hold: rendering then re-parsing preserves structure. Two families are outside it, both because the subset has no text for them: the two code-span shapes named below, and a container with nothing in it to write — an `MdList` with no items or an `MdTable` with no rows renders to no lines, so it re-parses to no block. (An empty `MdBlockQuote` and an empty list *item* are inside the property: each has a form, given below.) Every runtime produces the same string (§12.9.3). Four rules carry that property. The first two follow from the ADT having no line-break node (see the design note above); the last two from a container needing to be readable back as one block: @@ -2276,10 +2276,48 @@ Four rules carry that property. The first two follow from the ADT having no line - **A container separates its children.** A block quote writes a bare `>` between adjacent children, as a document writes a blank line between its own. Without it two quoted paragraphs render as two adjacent quoted lines, which re-parse as one paragraph. An empty child still occupies its line: a `MdBlockQuote` with no children renders `>`, and a list item with no blocks renders its marker followed by a space, which is what the item patterns read back — a bare `-` is a paragraph. Rendering either as nothing deletes it, and in an ordered list renumbers every item after it. A container with nothing to render at all — a list with no items, a table with no rows — contributes no lines **and** no separator, because a separator standing for an absent block is a blank line the next parse cannot attribute to anything. - **A code span is fenced longer than its content.** The fence is one backtick more than the longest backtick run inside the span, with a single padding space on each side when the content itself starts or ends with a backtick, or when it both starts and ends with a space — a parser strips one such pair, so the pad is what it removes instead of the content's own spaces. A fixed-width fence terminates on a run inside the content. -Together these should make `md_render` a fixed point: re-parsing and re-rendering its output returns the same bytes. +Together these MUST make `md_render` a fixed point: re-parsing and re-rendering its output returns the same bytes. Unlike the round-trip property this one has no exceptions — a block with no text renders to no lines, which re-renders to no lines. Two code-span shapes are outside that, and are lost identically on every runtime rather than differently, because the subset has no escape syntax to write them another way: a span needing three or more backticks, at the start of a line, where that run is a fenced-code-block opener; and an empty span, whose rendering reads back as literal text. +The rules are visible from a value a program builds, which is where they +bite: a parser cannot produce an empty list item or a space-bounded code +span, so only a constructed `MdBlock` reaches them. + +``` +-- A container prefixes every line of every child, separates adjacent +-- children with a bare '>', and still writes a line for an empty one. +public fn quote_rules(@Unit -> @String) + requires(true) + ensures(true) + effects(pure) +{ + md_render(MdDocument([MdBlockQuote([MdParagraph([MdText("one")]), MdCodeBlock("sh", "a\nb")]), MdBlockQuote([])])) +} +``` + +Renders `> one`, `>`, `> ```sh`, `> a`, `> b`, `> ``` `, a blank line, and +`>` — the fenced block's body carries the prefix on every line, the bare +`>` separates the quote's two children, and the empty quote still occupies +its own line. + +``` +-- A code span is fenced longer than its content, and padded when the +-- content would otherwise merge with the fence or lose its own spaces. +public fn span_rules(@Unit -> @String) + requires(true) + ensures(true) + effects(pure) +{ + md_render(MdDocument([MdParagraph([MdCode("a`b"), MdText(" "), MdCode(" x ")]), MdList(false, [[]])])) +} +``` + +Renders ``` ``a`b`` ` x ` ``` and then `- `: the first span's fence is +one backtick longer than the run inside it, the second is padded so the +parser's strip removes the pad rather than the content's own spaces, and +the empty item keeps its place as a marker and a space. + **Accessor functions for contracts:** From b713cdf4a564ee346ff9f0070da26ea4ed89f816 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 05:14:36 +0100 Subject: [PATCH 17/69] Say which whitespace decimal_from_string ignores, and stop eating a BOM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §9.7.2 already refused to inherit its accepted domain — "defined by this grammar rather than inherited from whatever the host library parses" — but the whitespace half was inherited anyway, from str.strip on one host and String.prototype.trim on the other. Those sets differ in both directions, so the same .wasm parted the runtimes on six code points: U+001C..U+001F and U+0085 were Some natively and None in the browser, U+FEFF the other way round. U+00A0 and the Unicode space separators were accepted by both, for reasons neither specification names. The set is now stated, and it is not a new one: the six code points is_whitespace already names — tab, LF, VT, FF, CR, space. That keeps decimal_from_string consistent with the four other sites that test for whitespace, whose shared helper carries a comment warning that open-coded copies silently diverge. This was the fifth site and it had open-coded nothing at all, which is how it inherited two different answers. Nothing in tests, examples or the conformance corpus pads a decimal with anything outside the stated set. The U+FEFF half turned out not to be about trimming. new TextDecoder('utf-8') defaults to ignoreBOM: false, whose meaning is the reverse of its name: it REMOVES a byte-order mark from the front of the buffer. Every Vera string reaching a host binding goes through that decoder, so IO.print("\u{FEFF}x") printed x, json_parse accepted a BOM-prefixed document the reference host refuses, and md_parse dropped the character out of its text. These bytes are a string payload, not a document with an encoding signature, and safe_utf8_decode never stripped one — so the decoder now passes it through. Its own parity battery covers the three families, each against the expected string as well as across hosts, since two hosts both dropping the mark would satisfy equality alone. Held to the same zero-regression bar as the renderer work, because the decoder feeds both parsers: over 6,065 shared corpus cases the before/after transition matrix shows no ok->broken movement on ADT agreement, render agreement, or either host's fixed-point property. The FAQ said the runtime implements "all Vera host bindings", which the paragraph two lines below contradicts. It now names the boundary by its shape — a filesystem, an accept loop, a database and a model provider are outside what a page can host — and points at §12.9.3, which lists each and why. CHANGELOG records the exponent bound as what it is: |exp| <= 999999 is unchanged in force and in value, and only its keyword moved, from a lowercase "must" to the MUST it was always enforced as. Co-Authored-By: Claude --- CHANGELOG.md | 2 + FAQ.md | 4 +- README.md | 2 +- ROADMAP.md | 2 +- TESTING.md | 4 +- docs/llms-full.txt | 4 +- spec/09-standard-library.md | 2 +- tests/test_browser.py | 128 ++++++++++++++++++++++++++++++++++++ vera/README.md | 2 +- vera/browser/runtime.mjs | 27 +++++++- vera/runtime/decimal.py | 13 +++- 11 files changed, 176 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 751e258b..3dfd79ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **`new(State)` reads the cell its contract names** ([#1285](https://github.com/aallan/vera/issues/1285)). `old(State)` has been keyed on the resolved cell family since [#1205](https://github.com/aallan/vera/issues/1205)/[#1209](https://github.com/aallan/vera/issues/1209); `new(State)` read the name-keyed `_effect_ops["get"]`, which holds whichever `State` the effect row registered first. Under a single-`State` row the two keyings coincide, which is why the corpus agreed; under a multi-`State` row the two sides of one `ensures` clause read different cells. `effects(, State>)` with `ensures(new(State) == false)` was check-green *and* verify-green, emitted `state_get_Int`'s i64 into the Bool comparison's `i32.eq`, and died at load with wasmtime's raw `type mismatch: expected i32, found i64`. The type mismatch is the symptom rather than the defect: where both cells share a machine width — `State` beside `State` — the module loaded and answered about the other cell, so `ensures(new(State) == old(State))` on a function that writes neither was refuted at runtime on a contract the verifier had discharged. Codegen now carries a family→getter registry populated at the declared-row registration site from the per-family `CellNames` it already computes, and `_translate_new_expr` keys on `_state_effect_family` exactly as `_translate_old_expr` does. A bare `get(())` names no family and so is right to keep reading the name-keyed registry, source-order-first-wins; a contract names one and must not. +- **`decimal_from_string` ignores one stated whitespace set, and a leading byte-order mark survives the browser boundary** ([#856](https://github.com/aallan/vera/issues/856) review). §9.7.2 said the grammar is applied "after ignoring surrounding whitespace" and that the accepted domain is defined by the grammar "rather than inherited from whatever the host library parses" — but the whitespace half *was* inherited, from `str.strip` on the reference host and `String.prototype.trim` in the browser, and those two sets differ in both directions. Measured through identical module bytes: `U+001C`–`U+001F` and `U+0085` around a decimal were `Some` natively and `None` in the browser, `U+FEFF` was `None` natively and `Some` in the browser, and `U+00A0` with the Unicode space separators were accepted by both for reasons neither specification names. §9.7.2 now states the set, and it is the one the language already had: the six code points `is_whitespace` names. **The `U+FEFF` half turned out not to be about trimming at all** — `new TextDecoder('utf-8')` defaults to `ignoreBOM: false`, whose meaning is the reverse of its name, so the browser's `readString` **removed** a byte-order mark from the front of every string crossing into a host binding. `IO.print("\u{FEFF}x")` printed `x`, `json_parse` accepted a BOM-prefixed document the reference host refuses, and `md_parse` dropped the character from its text. The decoder now passes it through, matching `safe_utf8_decode`, which never stripped one. The exponent bound `|exp| <= 999999` is unchanged in force and in value; only its keyword is, from a lowercase "must" to the RFC 2119 MUST it was always enforced as. + - **Three constructed `MdBlock` values the renderer could not write back** ([#1294](https://github.com/aallan/vera/issues/1294) review). All three are reachable only from a value a program *builds*, which is why a round-trip corpus could not find them: the parser never produces the shape that breaks. **A code span whose content starts and ends with a space** was eaten by the parser's own strip — `_parse_inlines` removes one such pair whenever the fenced text is two characters or longer, so `MdCode(" x ")` rendered `` ` x ` `` and read back as `MdCode("x")`, and `MdCode(" `x` ")` rendered to the same bytes as ``MdCode("`x`")``, which made the loss unrecoverable even by guessing. The renderer now pads those spans the same way it already padded backtick-bounded ones, so the strip removes the pad instead of the content; spec §9.7.3 loses one of its three documented round-trip losses as a result. **A list item with no blocks** was dropped outright, though `- ` is exactly what the parser reads back as one — and in an ordered list dropping it silently renumbered every item after it. It now renders as its marker plus the space both item patterns require (a bare `-` is a paragraph). **A container that renders to nothing** — a list with no items, a table with no rows — still drew the document's blank-line separator, so `MdDocument([MdList([]), p])` rendered `"\nafter"`: a blank line standing for an absent block, which the next parse cannot attribute to anything and which cost the render its fixed-point property. A zero-line child now contributes no separator, in `MdBlockQuote` as well as `MdDocument`. Both runtimes move together and the two spec rules are restated; the change is held to a zero-regression bar over the 6,078-case Markdown corpus — ADT agreement, render agreement and each host's fixed-point property all show no ok→broken transition. §9.7.3's two normative sentences are stated at the strength the batteries enforce while the section is open: the fixed point is unconditional, so it reads MUST with no carve-out, and the round-trip property reads MUST with an exception clause that now names *both* families outside it — the two unwriteable code spans, and a container with nothing in it to write, since an `MdList` with no items renders to no lines and so re-parses to no block. The earlier clause named only the code spans, which made the property false for the empty containers it did not mention. Each rule cluster also gains a compiled, contract-verified example, because every one of them is reachable only from a constructed value and the section had no executable form of that. - **A block quote separates and keeps its children, on both runtimes** ([#1294](https://github.com/aallan/vera/issues/1294) review). The **reference** renderer emitted no separator between a blockquote's children, so `MdBlockQuote([Para, Para])` — the shape `md_parse` builds from `> a\n>\n> b` — rendered as two adjacent quoted lines and read back as **one** paragraph. Structure lost silently, on both hosts, and the round-trip property spec §9.7.3 states did not hold for it. A quote with no children was the same defect one size down: it rendered as no lines at all, so a `>` in a document vanished on the round trip and left the enclosing separator dangling (`---\n>` came back as `---`). Both arms now mirror `MdDocument`'s: a bare `>` between children, and `>` for an empty quote. Two more mirrors land with them. The **browser parser**'s blockquote reader required `> ` with the space where the reference accepts `^>\s?`, and had no lazy-continuation branch at all, so `>no space` parsed as literal text and `> a\nb` pushed the second line out of the quote — both inside #1294's stated scope and neither closed by the renderer fix. And a **code span** is now fenced with one backtick more than its longest internal run rather than a fixed two, padded only when the content starts or ends with a backtick: the old rule was right for one backtick and wrong for two, since ``` `` a``b `` ``` closes on the run *inside* the content. The browser's inline parser scanned for the next single backtick rather than a run of equal length, so it could not read that back either; it now counts runs like the reference. Spec §9.7.3 states all four rules. Measured on a 1,471-input adversarial corpus: `md_render` is a fixed point on **every** input on both hosts (from 65 and 54 unstable), and cross-host render divergence falls from 489 inputs to 34; on 4,850 sections of the project's own documentation, render divergence falls from 40 to 11 and the reference round trip is a fixed point everywhere. The remaining `md_parse` divergences are a separate tracked bug, listed in `KNOWN_ISSUES.md` with a measured class-by-class breakdown. Alongside them: the browser's `json_stringify` no longer falls back to `"null"` when `JSON.stringify` returns `undefined` — unreachable from `readJson`, but the same silent substitution [#1293](https://github.com/aallan/vera/issues/1293) removed one layer up, and an unreachable branch is where a silent wrong answer survives; and the non-finite parity test now asserts the *whole* shared sentence, taken from the reference implementation so the browser's hand-copied duplicate is held against the original, plus that neither host printed anything before failing. diff --git a/FAQ.md b/FAQ.md index a94f5c7f..efd1cf65 100644 --- a/FAQ.md +++ b/FAQ.md @@ -170,7 +170,7 @@ vera compile --target browser examples/hello_world.vera # index.html ``` -Serve it with any HTTP server and open `index.html` — no build step, no bundler, no dependencies. The JavaScript runtime provides browser-appropriate implementations of all Vera host bindings: `IO.print` writes to the page, `IO.read_line` uses `prompt()`, and State, contracts, JSON serialization and Markdown rendering work identically to the wasmtime runtime. `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states — §9.7.1 and §9.7.3 — rather than by the two hosts happening to agree, which is what the parity suite checks them against. The two *parsers* are where the runtimes still differ, and each difference is tracked. `json_parse` disagrees on inputs outside RFC 8259: the reference host accepts `NaN` and the infinities where the browser refuses them ([#1306](https://github.com/aallan/vera/issues/1306)), and a `\uD800` escape becomes U+FFFD in the browser where the reference host fails outright ([#1308](https://github.com/aallan/vera/issues/1308)) — well-formed JSON parses the same on both. `md_parse` differs more widely: the two hand-written parsers disagree across nine measured classes of input the §9.7.3 subset leaves open, the largest by a wide margin being how a paragraph's plain-text runs are grouped — invisible to `md_render`, since the runs concatenate to the same text — and the rest render-visible, from how emphasis markers are scanned to block markers such as a `+` bullet or a list nested more than two deep. That one is tracked as [#1301](https://github.com/aallan/vera/issues/1301). +Serve it with any HTTP server and open `index.html` — no build step, no bundler, no dependencies. The JavaScript runtime provides browser-appropriate implementations of the host bindings the browser target supports — the ones a page can host, which leaves a filesystem, an accept loop, a database and a model provider outside it by construction (spec §12.9.3 lists each and why): `IO.print` writes to the page, `IO.read_line` uses `prompt()`, and State, contracts, JSON serialization and Markdown rendering work identically to the wasmtime runtime. `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states — §9.7.1 and §9.7.3 — rather than by the two hosts happening to agree, which is what the parity suite checks them against. The two *parsers* are where the runtimes still differ, and each difference is tracked. `json_parse` disagrees on inputs outside RFC 8259: the reference host accepts `NaN` and the infinities where the browser refuses them ([#1306](https://github.com/aallan/vera/issues/1306)), and a `\uD800` escape becomes U+FFFD in the browser where the reference host fails outright ([#1308](https://github.com/aallan/vera/issues/1308)) — well-formed JSON parses the same on both. `md_parse` differs more widely: the two hand-written parsers disagree across nine measured classes of input the §9.7.3 subset leaves open, the largest by a wide margin being how a paragraph's plain-text runs are grouped — invisible to `md_render`, since the runs concatenate to the same text — and the rest render-visible, from how emphasis markers are scanned to block markers such as a `+` bullet or a list nested more than two deep. That one is tracked as [#1301](https://github.com/aallan/vera/issues/1301). Two effects are refused outright rather than merely differing. `Inference` and `DB` return an explanatory `Err` from every operation in the browser, because the API key or database credential they would need is readable from page source and network traffic in client-side JavaScript. Reach them through a server-side endpoint and call it with `Http`, which does run in the browser — it is backed by `XMLHttpRequest`, not a stub. That refusal is a deliberate platform boundary; spec §9.5.5 states it for `Inference`. @@ -279,7 +279,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 10,759 tests, including a 216-program conformance suite +- 10,803 tests, including a 216-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/README.md b/README.md index 7e914b2a..17d2fee9 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ cp /path/to/vera/SKILL.md ~/.claude/skills/vera-language/SKILL.md ## Project status -Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 10,759 tests, 95% Python code coverage, 216 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. +Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 10,803 tests, 95% Python code coverage, 216 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. The reference compiler — parser, AST, type checker, contract verifier (Z3), WASM code generator, module system, browser runtime, and runtime contract insertion — is working. The language specification is in draft across [14 chapters](spec/). diff --git a/ROADMAP.md b/ROADMAP.md index 641e81fb..d8d72994 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,7 +12,7 @@ Ordering derives from the design principles ([DESIGN.md](DESIGN.md)): verificati ## Where we are -10,759 tests, 216 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. +10,803 tests, 216 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. ## Stage 19 — The verification completeness sprint diff --git a/TESTING.md b/TESTING.md index cdf12e6a..c042284a 100644 --- a/TESTING.md +++ b/TESTING.md @@ -6,7 +6,7 @@ This is the single source of truth for Vera's testing infrastructure, coverage d | Metric | Value | |--------|-------| -| **Tests** | 10,759 across 164 files (~155,000 lines of test code; 10,591 passed + 26 stress, 142 skipped) | +| **Tests** | 10,803 across 164 files (~155,000 lines of test code; 10,635 passed + 26 stress, 142 skipped) | | **Compiler code coverage** | 95% Python, 87% JavaScript (CI minimum: 80%) | | **Conformance programs** | 216 programs across 9 spec chapters, validating every language feature | | **Example programs** | 42, all validated through `vera check` + `vera verify` | @@ -196,7 +196,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_tester_coverage.py` | 49 | 1,395 | Tester coverage gaps: String/Float64/ADT parameter input generation, Bool/Byte parameters, unsatisfiable preconditions, type expression edge cases, FP model-value extraction (NaN/Inf/signed-zero, #797), the #1208 naming pin (the threaded alias environment canonicalizes a type-argument alias), and the #1216 resolution set — an alias-typed parameter is trialed rather than skipped, its alias-spelled `requires` constrains the generated inputs, a refined alias reaches Z3 with its predicate so no trial violates codegen's entry guard, a `forall` variable shadowing a same-named module alias still resolves to an unsupported type variable, and a parameter that resolves to a non-encodable type still skips with that type named; plus the #1229 set — an UNTRANSLATABLE input constraint skips the function naming the blocking conjunct instead of scoring the resulting entry-guard trap as a contract failure, on the reported `string_length` repro, on a mixed clause (only the untranslatable conjunct is named), on a quantified precondition and on a refined parameter's predicate, with a Tier-3 control that must still be tested so a skip taxonomy firing on everything cannot pass | | `test_markdown.py` | 94 | 610 | Markdown parser: block/inline parsing, rendering, round-trips, edge cases | | `test_lsp.py` | 146 | 2470 | LSP transport + coordinate layer (#222 Phase C) and language features (#222 Phase D): parametrized code-point↔UTF-16 goldens incl. astral-plane fixtures and surrogate-pair snapping, Span (1-based, exclusive-end) and SourceLocation (0-based col) → LSP Range conversions, point→token-range widening, DocumentStore open/change/close + index invalidation, an in-process handler-drive test, and one stdio end-to-end round-trip against the real `vera lsp` subprocess (initialize → didOpen → shutdown → exit) pinning serverInfo + textDocumentSync capabilities; plus the Phase D feature suite — parse-error single-diagnostic path, type-error verification short-circuit, tier=3 in E520 diagnostic data, per-function tier Hint synthesis (and its suppression for functions with violated obligations), smallest-enclosing-span hover, De Bruijn slot goto (most-recent-parameter jump, out-of-range None, off-slot None, and the #1208 keying pins: a parameterised reference resolves, an alias-spelled parameter is reachable from a canonically-spelled reference, and a `forall` variable shadowing a module alias lands on the right parameter), and typed-hole completion (inside/after hole, away-from-hole None); plus the Phase E speculativeEdit suite — identical-text all-unchanged, breaking edit surfaces newly_undischarged (violated nat_sub) with canonical state untouched, strengthening edit surfaces newly_discharged, parse/type errors report ok:false, deleted functions report removed, proof_delta purity; plus the Phase F1 proposeEdit suite — the apply gate (clean and strengthening edits apply, breaking and non-compiling edits refuse), force overriding both gates with the delta still reported, wiring against a structural fake server (apply round-trip with exact full-document replacement range, refuse touches no canonical state, unopened-URI clamp sentinel), and full-document-range goldens (trailing-newline virtual line, UTF-16 end column); plus the Phase F2 strengthenContract suite — splice goldens (first-clause-only replacement with byte-identical remainder, ensures variant, unknown-fn None), the call-site audit pin (tightened precondition refused with newly_undischarged call_pre items, canonical state untouched), provable-ensures strengthening applies, and the three splice-target refusal paths (no analysis, unparseable document, unknown function); plus the Phase F3 addEffect suite — transitive-caller closure goldens (diamond in declaration order, leaf, unknown-fn None, recursion appears once), handler bounding (#725: a caller discharging the effect around its only call site drops out of the closure and is not rewritten, while a second unhandled path, a call in a handler clause, a handler for another effect, a handler naming a different instance of the same effect (`handle[State]` against a `State` propagation, which the checker does not discharge — end-to-end that caller must still be rewritten and the candidate must still apply, with the matching `State` propagation against the same fixture as the positive control that this handler key does prune something), and a bare `where`-helper call all keep it in, as does a refinement type argument at either depth (`Exn<{ @Int \| p }>` and `Exn>` render as their bare base type but discharge nothing of `Exn` — also pinned end-to-end), while an unparameterised `handle[IO]` does bound an `IO` propagation, a whitespace-spelled `State< Int >` request still bounds, and nesting bounds in either order (a matching handler inside a foreign one, a foreign one inside a matching one) — plus a key-level pin that `handle[Mod.IO]` keeps its module, and the effect-less query pinned handler-unaware; plus the two boundary pins the review added — a call in the handler's STATE INITIALISER keeps its edge, since the initialiser is evaluated in the enclosing scope before the handler is installed (pinned beside the E125 the checker raises there against a `pure` caller, with the identical call in the handler body clean as the contrast), and an alias-spelled handler does not bound a `State` propagation though the checker discharges it (`handle[State]` with `type MyAlias = Int` — the spelling comparison's under-prune, #1292, with the alias-spelled request as the control that does prune)), effect-row rewrite goldens (pure to singleton set, source-preserving append, already-present None, base-name identity blocking State next to State), diamond propagation applying one multi-site candidate with the bystander untouched, mixed append/replace rows with already-satisfied callers skipped, the fully-satisfied no-op shape, and the two refusal paths; plus the #728 instruction-contract suite — the LSP message carries description, rationale, and the Fix: paragraph (also pinning single E501 emission at the LSP surface), and a bare diagnostic maps to the description alone | -| `test_browser.py` | 303 | 4,605 | Browser parity: Python/wasmtime vs Node.js/JS-runtime output equivalence across IO, State, contracts, Markdown, Regex, and the examples the browser target can execute (two explicit lists in the file, not the whole `examples/` directory — interactive stdin, file IO, `DB` and the non-standalone `modules` example are excluded with their reasons recorded); plus the #349 `runtime.mjs` coverage battery — per-value-type and per-key-type Map variants, per-element-type Set variants, cold `Decimal` branches (exact-zero sign, negative-shift division, `decRoundPlaces` special cases, non-finite storage), `readJson`/`json_stringify` across every Json ADT tag, the Regex/Json `Result.Err` arms, and nested-Markdown walks. Two operations carry a canonical form the specification states rather than merely agreeing across the hosts, so their batteries assert more than equality: `json_stringify` (spec §9.7.1) pins the expected string on every Json ADT tag and on the number-rendering boundaries, checks three-pass idempotence, checks that a non-finite `JNumber` fails on both hosts *and* prints nothing, pins the object key orders an ordinary JS object cannot carry (array-index keys, which ES enumeration hoists to the front in ascending numeric order, and a `__proto__` key, whose assignment writes a prototype instead of a field) on both a parsed and a program-built object, and checks the reference host's own number rendering differentially against a real `JSON.stringify` over doubles drawn from raw bit patterns; `md_render` (§9.7.3) pins the expected render, re-renders it to prove the fixed point, runs the round-trip property over a corpus carrying the container and multi-line shapes a flat corpus misses, and renders `MdBlock` values the test builds directly, since several renderer rules — a container's child separator, an empty container, a code span wider than one backtick — are unreachable through `md_parse`. `md_parse` itself is not yet at parity and its remaining divergence classes are a tracked bug ([#1301](https://github.com/aallan/vera/issues/1301)); the suite pins the shapes the two implementations agree on | +| `test_browser.py` | 347 | 4,733 | Browser parity: Python/wasmtime vs Node.js/JS-runtime output equivalence across IO, State, contracts, Markdown, Regex, and the examples the browser target can execute (two explicit lists in the file, not the whole `examples/` directory — interactive stdin, file IO, `DB` and the non-standalone `modules` example are excluded with their reasons recorded); plus the #349 `runtime.mjs` coverage battery — per-value-type and per-key-type Map variants, per-element-type Set variants, cold `Decimal` branches (exact-zero sign, negative-shift division, `decRoundPlaces` special cases, non-finite storage), `readJson`/`json_stringify` across every Json ADT tag, the Regex/Json `Result.Err` arms, and nested-Markdown walks. Two operations carry a canonical form the specification states rather than merely agreeing across the hosts, so their batteries assert more than equality: `json_stringify` (spec §9.7.1) pins the expected string on every Json ADT tag and on the number-rendering boundaries, checks three-pass idempotence, checks that a non-finite `JNumber` fails on both hosts *and* prints nothing, pins the object key orders an ordinary JS object cannot carry (array-index keys, which ES enumeration hoists to the front in ascending numeric order, and a `__proto__` key, whose assignment writes a prototype instead of a field) on both a parsed and a program-built object, and checks the reference host's own number rendering differentially against a real `JSON.stringify` over doubles drawn from raw bit patterns; `md_render` (§9.7.3) pins the expected render, re-renders it to prove the fixed point, runs the round-trip property over a corpus carrying the container and multi-line shapes a flat corpus misses, and renders `MdBlock` values the test builds directly, since several renderer rules — a container's child separator, an empty container, a code span wider than one backtick — are unreachable through `md_parse`. `md_parse` itself is not yet at parity and its remaining divergence classes are a tracked bug ([#1301](https://github.com/aallan/vera/issues/1301)); the suite pins the shapes the two implementations agree on | | `test_conformance.py` | 1080 | 125 | Parametrized conformance suite: parse, check, verify, run, format idempotency across 216 programs | | `test_prelude.py` | 29 | 585 | Prelude injection: Option/Result/array operation detection, combinator shadowing, type aliases, the reserved namespace every injected alias declaration lives in — checked against the checker's own E154 regex rather than a second spelling of the rule, since an alias the prelude injects outside it is one codegen resolves and the checker leaves opaque (#1184/#1221) — end-to-end compilation | | `test_checker_apply_fn.py` | 18 | 455 | #854 — `apply_fn` as a checker special form: zero-warning pins (API + CLI `--json` + closures.vera), E201 arity / E202 type / non-function-first-arg errors, E122/E125 effect-row enforcement for applied fn values, E151 redefinition rejection, variadic two-param application, prelude combinator regression pins | diff --git a/docs/llms-full.txt b/docs/llms-full.txt index bc7ae9d9..e5759100 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -3147,7 +3147,7 @@ vera compile --target browser examples/hello_world.vera # index.html ``` -Serve it with any HTTP server and open `index.html` — no build step, no bundler, no dependencies. The JavaScript runtime provides browser-appropriate implementations of all Vera host bindings: `IO.print` writes to the page, `IO.read_line` uses `prompt()`, and State, contracts, JSON serialization and Markdown rendering work identically to the wasmtime runtime. `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states — §9.7.1 and §9.7.3 — rather than by the two hosts happening to agree, which is what the parity suite checks them against. The two *parsers* are where the runtimes still differ, and each difference is tracked. `json_parse` disagrees on inputs outside RFC 8259: the reference host accepts `NaN` and the infinities where the browser refuses them ([#1306](https://github.com/aallan/vera/issues/1306)), and a `\uD800` escape becomes U+FFFD in the browser where the reference host fails outright ([#1308](https://github.com/aallan/vera/issues/1308)) — well-formed JSON parses the same on both. `md_parse` differs more widely: the two hand-written parsers disagree across nine measured classes of input the §9.7.3 subset leaves open, the largest by a wide margin being how a paragraph's plain-text runs are grouped — invisible to `md_render`, since the runs concatenate to the same text — and the rest render-visible, from how emphasis markers are scanned to block markers such as a `+` bullet or a list nested more than two deep. That one is tracked as [#1301](https://github.com/aallan/vera/issues/1301). +Serve it with any HTTP server and open `index.html` — no build step, no bundler, no dependencies. The JavaScript runtime provides browser-appropriate implementations of the host bindings the browser target supports — the ones a page can host, which leaves a filesystem, an accept loop, a database and a model provider outside it by construction (spec §12.9.3 lists each and why): `IO.print` writes to the page, `IO.read_line` uses `prompt()`, and State, contracts, JSON serialization and Markdown rendering work identically to the wasmtime runtime. `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states — §9.7.1 and §9.7.3 — rather than by the two hosts happening to agree, which is what the parity suite checks them against. The two *parsers* are where the runtimes still differ, and each difference is tracked. `json_parse` disagrees on inputs outside RFC 8259: the reference host accepts `NaN` and the infinities where the browser refuses them ([#1306](https://github.com/aallan/vera/issues/1306)), and a `\uD800` escape becomes U+FFFD in the browser where the reference host fails outright ([#1308](https://github.com/aallan/vera/issues/1308)) — well-formed JSON parses the same on both. `md_parse` differs more widely: the two hand-written parsers disagree across nine measured classes of input the §9.7.3 subset leaves open, the largest by a wide margin being how a paragraph's plain-text runs are grouped — invisible to `md_render`, since the runs concatenate to the same text — and the rest render-visible, from how emphasis markers are scanned to block markers such as a `+` bullet or a list nested more than two deep. That one is tracked as [#1301](https://github.com/aallan/vera/issues/1301). Two effects are refused outright rather than merely differing. `Inference` and `DB` return an explanatory `Err` from every operation in the browser, because the API key or database credential they would need is readable from page source and network traffic in client-side JavaScript. Reach them through a server-side endpoint and call it with `Http`, which does run in the browser — it is backed by `XMLHttpRequest`, not a stub. That refusal is a deliberate platform boundary; spec §9.5.5 states it for `Inference`. @@ -3256,7 +3256,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 10,759 tests, including a 216-program conformance suite +- 10,803 tests, including a 216-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/spec/09-standard-library.md b/spec/09-standard-library.md index f5a9e48d..7db80ebf 100644 --- a/spec/09-standard-library.md +++ b/spec/09-standard-library.md @@ -2155,7 +2155,7 @@ Decimal is an opaque built-in type implemented via host imports, following the s | `decimal_to_string(d)` | `(Decimal) → String` | String representation | | `decimal_to_float(d)` | `(Decimal) → Float64` | Potentially lossy conversion to float | -**`decimal_from_string` grammar:** both runtimes accept exactly the language `[+-]? ( digits ( "." digits? )? | "." digits ) ( ("e" | "E") [+-]? digits )?` where `digits` is one or more ASCII `0`–`9`, applied after ignoring surrounding whitespace, and the exponent token (when present) MUST satisfy `|exp| <= 999999` — the default context's exponent floor, cited by the `decimal_round` fallback below and chosen to keep operand magnitudes bounded and the exponent-token check exact. Only finite decimals are accepted: special values (`NaN`, `sNaN`, `Infinity`), digit-group underscores (`1_000`), non-ASCII digits, and out-of-range exponent tokens are all rejected with `None`, even where a host decimal library would accept them. The accepted domain is defined by this grammar rather than inherited from whatever the host library parses (DESIGN.md: explicit over implicit) — the Python host pre-validates with this grammar before constructing a `decimal.Decimal`, and the browser runtime's parser recognises the same language, checking the exponent token as a string before any numeric conversion (an unbounded token would otherwise round silently above 2^53). This `|exp| <= 999999` bound constrains **input literals** only; exact arithmetic on accepted operands can grow the exponent past it — `decimal_mul(decimal_from_string("1e999999"), decimal_from_string("1e999999"))` yields `1E+1999998` — and such results are computed and rendered identically in both runtimes (the Python host runs the binary operations in a context whose exponent range is widened to the library maximum, `±10^18`, so a finite result never overflows and matches the browser's unbounded engine). +**`decimal_from_string` grammar:** both runtimes accept exactly the language `[+-]? ( digits ( "." digits? )? | "." digits ) ( ("e" | "E") [+-]? digits )?` where `digits` is one or more ASCII `0`–`9`, applied after ignoring surrounding whitespace — the six code points `is_whitespace` names (`0x09`, `0x0A`, `0x0B`, `0x0C`, `0x0D`, `0x20`) and no others, stated here because the two host libraries' own trim functions disagree about the rest in both directions, and the exponent token (when present) MUST satisfy `|exp| <= 999999` — the default context's exponent floor, cited by the `decimal_round` fallback below and chosen to keep operand magnitudes bounded and the exponent-token check exact. Only finite decimals are accepted: special values (`NaN`, `sNaN`, `Infinity`), digit-group underscores (`1_000`), non-ASCII digits, and out-of-range exponent tokens are all rejected with `None`, even where a host decimal library would accept them. The accepted domain is defined by this grammar rather than inherited from whatever the host library parses (DESIGN.md: explicit over implicit) — the Python host pre-validates with this grammar before constructing a `decimal.Decimal`, and the browser runtime's parser recognises the same language, checking the exponent token as a string before any numeric conversion (an unbounded token would otherwise round silently above 2^53). This `|exp| <= 999999` bound constrains **input literals** only; exact arithmetic on accepted operands can grow the exponent past it — `decimal_mul(decimal_from_string("1e999999"), decimal_from_string("1e999999"))` yields `1E+1999998` — and such results are computed and rendered identically in both runtimes (the Python host runs the binary operations in a context whose exponent range is widened to the library maximum, `±10^18`, so a finite result never overflows and matches the browser's unbounded engine). **Arithmetic:** diff --git a/tests/test_browser.py b/tests/test_browser.py index 3a330a0e..327b0306 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -2675,6 +2675,134 @@ def test_eager_gc_md_extract_code_blocks_volume( assert self._eager_gc_node(src, monkeypatch, tmp_path) == "60" +# (id, code point, label). Every character either runtime's built-in +# trim treats as whitespace, plus the six §9.7.2 now names. The two +# host libraries disagree about this set in BOTH directions, which is +# why the rule has to be written down rather than inherited: Python's +# ``str.strip`` takes U+001C–U+001F and U+0085, JavaScript's ``trim`` +# does not; ``trim`` takes U+FEFF, ``strip`` does not. +_DECIMAL_WS_CASES = [ + # The set §9.7.2 states — the same one `is_whitespace` uses. + ("tab", 0x09, True), ("lf", 0x0A, True), ("vt", 0x0B, True), + ("ff", 0x0C, True), ("cr", 0x0D, True), ("space", 0x20, True), + # Python-only: the four information separators and NEL. + ("fs", 0x1C, False), ("gs", 0x1D, False), ("rs", 0x1E, False), + ("us", 0x1F, False), ("nel", 0x85, False), + # Accepted by both built-ins, in neither runtime's stated set. + ("nbsp", 0xA0, False), ("ogham", 0x1680, False), + ("en_quad", 0x2000, False), ("line_sep", 0x2028, False), + ("para_sep", 0x2029, False), ("narrow_nbsp", 0x202F, False), + ("mmsp", 0x205F, False), ("ideographic", 0x3000, False), + # JavaScript-only: the byte-order mark. + ("bom", 0xFEFF, False), +] + + +class TestBrowserDecimalWhitespaceSet856: + """`decimal_from_string` ignores ONE stated whitespace set (#1303 + review). + + §9.7.2 says the grammar is "applied after ignoring surrounding + whitespace" and that the accepted domain is defined by the grammar + "rather than inherited from whatever the host library parses" — but + the whitespace half was inherited, from ``str.strip`` on one host + and ``String.prototype.trim`` on the other. Those two sets differ + in both directions, so six code points parted the runtimes: a + decimal wrapped in U+0085 was ``Some`` natively and ``None`` in the + browser, and one wrapped in U+FEFF was the other way round. + + The set is now the one the language already states for + `is_whitespace` (§9.7.x): tab, LF, VT, FF, CR, space. Nothing else + is trimmed on either runtime, so a decimal padded with a no-break + space is refused by both rather than accepted by both for reasons + neither specification names. + """ + + _SRC = """ +public fn main(@Unit -> @Unit) + requires(true) ensures(true) effects() +{{ + match decimal_from_string("{lit}") {{ + Some(@Decimal) -> IO.print("ACCEPT"), + None -> IO.print("REFUSE") + }} +}} +""" + + @pytest.mark.parametrize( + ("case_id", "code_point", "trimmed"), + _DECIMAL_WS_CASES, + ids=[c[0] for c in _DECIMAL_WS_CASES], + ) + @pytest.mark.parametrize("where", ["leading", "trailing"]) + def test_whitespace_acceptance_agrees( + self, where: str, case_id: str, code_point: int, trimmed: bool, + tmp_path: Path, + ) -> None: + esc = f"\\u{{{code_point:04X}}}" + lit = esc + "1.5" if where == "leading" else "1.5" + esc + expected = "ACCEPT" if trimmed else "REFUSE" + out = _parity_stdout( + self._SRC.format(lit=lit), tmp_path, f"decws_{where}_{case_id}", + ) + assert out == expected + + +class TestBrowserLeadingBomParity1303: + """A leading U+FEFF survives the trip into the browser host. + + ``new TextDecoder('utf-8')`` defaults to ``ignoreBOM: false``, whose + meaning is the reverse of its name: it REMOVES a byte-order mark at + the start of the buffer. Every Vera string reaching a host binding + goes through that decoder, so any string whose first character was + U+FEFF arrived one character shorter than it left — while the + reference host's ``safe_utf8_decode`` passes it straight through. + + Found from the `decimal_from_string` whitespace work, where it was + the one code point still diverging after both hosts agreed on a + trim set; the cause turned out to have nothing to do with trimming + and to reach much further than `Decimal`. The cases below are the + three families that showed it, each asserted for cross-host + equality *and* against the expected string, since two hosts both + dropping the mark would satisfy equality alone. + """ + + @pytest.mark.parametrize(("case_id", "body", "expected"), [ + # The mark is the first character of the buffer — the only + # position the default decoder strips. + ("print", 'IO.print("\\u{FEFF}x")', "x"), + # Control: not first, so it was never at risk. Pinned so a + # future "fix" that strips U+FEFF everywhere goes red. + ("print_trailing", 'IO.print("x\\u{FEFF}")', "x"), + # A BOM-prefixed document is not JSON; both hosts must refuse. + ( + "json_parse", + 'match json_parse("\\u{FEFF}{}") { Ok(@Json) -> IO.print("OK"),' + ' Err(@String) -> IO.print("ERR") }', + "ERR", + ), + # Markdown keeps it as text rather than losing it. + ( + "md_parse", + 'match md_parse("\\u{FEFF}hi") {' + ' Ok(@MdBlock) -> IO.print(md_render(@MdBlock.0)),' + ' Err(@String) -> IO.print("ERR") }', + "hi", + ), + ]) + def test_leading_bom_is_not_swallowed( + self, case_id: str, body: str, expected: str, tmp_path: Path, + ) -> None: + src = f""" +public fn main(@Unit -> @Unit) + requires(true) ensures(true) effects() +{{ + {body} +}} +""" + assert _parity_stdout(src, tmp_path, f"bom_{case_id}") == expected + + class TestBrowserDecimalExact856: """Browser↔native Decimal parity (#856). diff --git a/vera/README.md b/vera/README.md index 57ac4f80..0a3daa10 100644 --- a/vera/README.md +++ b/vera/README.md @@ -752,7 +752,7 @@ The `ERROR_CODES` dict in `errors.py` maps every code to a short description (15 ## Test Suite -Testing spans a **pytest suite** of 10,759 tests across 164 files — compiler-internals unit tests plus a **conformance suite** (216 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. +Testing spans a **pytest suite** of 10,803 tests across 164 files — compiler-internals unit tests plus a **conformance suite** (216 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. See **[TESTING.md](../TESTING.md)** for the comprehensive testing reference -- test file table, conformance suite details, compiler code coverage, language feature coverage, helper conventions, validation scripts, CI pipeline, and guidelines for adding tests. diff --git a/vera/browser/runtime.mjs b/vera/browser/runtime.mjs index 1f3c73ca..063d169c 100644 --- a/vera/browser/runtime.mjs +++ b/vera/browser/runtime.mjs @@ -46,7 +46,17 @@ let cliArgs = []; // Command-line arguments for IO.args let envVars = {}; // Environment variables for IO.get_env let exitCode = null; // Set by IO.exit -const decoder = new TextDecoder('utf-8'); +// ``ignoreBOM: true`` means "do not treat a leading U+FEFF specially", +// which is the opposite of what the name suggests and the only setting +// that reads a Vera string back unchanged (#1303 review). The default +// (false) REMOVES a BOM at the start of the buffer, so every string +// whose first character was U+FEFF lost it crossing into the host: +// `IO.print` dropped it, `json_parse` silently accepted a +// BOM-prefixed document the reference host refuses, and +// `decimal_from_string` accepted a BOM-padded decimal. These bytes are +// a string payload, not a document with an encoding signature — the +// reference host's ``safe_utf8_decode`` never strips one. +const decoder = new TextDecoder('utf-8', { ignoreBOM: true }); const encoder = new TextEncoder(); // --------------------------------------------------------------------------- @@ -2200,6 +2210,17 @@ function buildImportObject(module, moduleBytes) { const DEC_PREC = 28n; const DEC_RE = /^([+-]?)(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/; + // The whitespace §9.7.2 states, rather than whatever the host's own + // trim happens to take (#1303 review). ``String.prototype.trim`` + // strips U+FEFF and every Unicode space separator but NOT + // U+001C..U+001F or U+0085, while the reference host's ``str.strip`` + // does the opposite on both counts — so the accepted domain diverged + // in both directions. This is the set ``is_whitespace`` already + // states: tab, LF, VT, FF, CR, space. Mirrors _ASCII_WS in + // vera/runtime/decimal.py. + const DEC_WS = /^[\t\n\v\f\r ]+|[\t\n\v\f\r ]+$/g; + const decStripWs = (str) => str.replace(DEC_WS, ""); + function decNumDigits(n) { return n === 0n ? 1 : n.toString().length; } @@ -2207,7 +2228,7 @@ function buildImportObject(module, moduleBytes) { // Parse a (canonical or user) decimal string to the value model. // Returns null on malformed input. function decParse(str) { - const m = str.trim().match(DEC_RE); + const m = decStripWs(str).match(DEC_RE); if (!m) return null; const intPart = m[2] || ""; const fracPart = m[3] || ""; @@ -2233,7 +2254,7 @@ function buildImportObject(module, moduleBytes) { // of -1000000 (a 999999 token plus a long fraction), and decimalGet // must keep round-tripping those. function decExpTokenInRange(str) { - const m = str.trim().match(DEC_RE); + const m = decStripWs(str).match(DEC_RE); if (!m || m[4] === undefined) return true; const digits = m[4].replace(/^[+-]/, "").replace(/^0+(?=\d)/, ""); return digits.length <= 6; // 6 digits: at most 999999 diff --git a/vera/runtime/decimal.py b/vera/runtime/decimal.py index e6770416..1e6f1a65 100644 --- a/vera/runtime/decimal.py +++ b/vera/runtime/decimal.py @@ -44,6 +44,17 @@ ) _DECIMAL_EXP_TOKEN_MAX = 999999 +# The whitespace §9.7.2 says the grammar is applied "after ignoring +# surrounding whitespace", stated explicitly rather than inherited from +# ``str.strip`` (#1303 review). Bare ``strip()`` takes Python's whole +# Unicode notion — U+001C..U+001F, U+0085, U+00A0, the Unicode space +# separators — while the browser's ``String.prototype.trim`` takes a +# DIFFERENT set that includes U+FEFF and excludes the first two groups, +# so the accepted domain diverged in both directions. This is the set +# ``is_whitespace`` already states (spec §9.7.x), which is the one the +# language has: tab, LF, VT, FF, CR, space. +_ASCII_WS = "\t\n\v\f\r " + # Binary arithmetic runs in a context whose exponent range is widened to # the library maximum (``MAX_EMAX`` / ``MIN_EMIN`` ~= ±1e18) while keeping # the default precision (28 significant digits) and rounding @@ -115,7 +126,7 @@ def host_decimal_from_float( def host_decimal_from_string( caller: wasmtime.Caller, ptr: int, length: int, ) -> int: - s = _read_wasm_string(caller, ptr, length).strip() + s = _read_wasm_string(caller, ptr, length).strip(_ASCII_WS) # Pre-validate against the spec §9.7.2 grammar so the # accepted domain matches the browser runtime exactly # (PyDecimal alone would also accept NaN / Infinity / From 3006686dfe77f5b576af81b940a4303c70819572 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 05:39:00 +0100 Subject: [PATCH 18/69] Make the Decimal example compile, and count the parsers that differ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §9.7.2 illustrated its exponent-bound point with decimal_mul(decimal_from_string(...), decimal_from_string(...)), which does not type-check: decimal_from_string yields Option and decimal_mul takes two Decimals, so the expression is E202. The gate could not see it because it reads fenced blocks and this was inline prose — the one block §9.7.x carries a skip annotation for is a different, unrelated one. It is now a real block that parses, checks and verifies, unwrapping with a match in the section's style, and it was RUN: it returns the 1E+1999998 the paragraph claims. The spec's validated count goes 89 -> 90. TESTING.md's conformance-suite runbook still said "parametrized — 1,070 tests" where the table row says 1,080; group A's two new conformance programs moved the row through the oracle and left the prose behind, which is precisely the shape check_doc_counts.py cannot gate. Swept the file for the stale value and for any other per-file count stated in prose rather than in the table: the only other numbers that look like counts are grid dimensions and generation limits in the stress-test descriptions. vera/README.md called md_parse "the one shared operation still divergent between the hosts", which stopped being true when json_parse's two exceptions were tabled. It now tells the same story the FAQ was corrected to tell: the operations carrying a canonical form on one side, the two parsers on the other, each parser's divergence named and tracked, and the note that every well-formed JSON document parses the same on both. Skip-changelog: documentation only — a spec illustration that never compiled, a stale count in a runbook, and a scope correction. No behaviour changes, and the [Unreleased] Decimal bullet already describes everything §9.7.2 now promises. Co-Authored-By: Claude --- TESTING.md | 2 +- spec/09-standard-library.md | 21 ++++++++++++++++++++- vera/README.md | 2 +- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/TESTING.md b/TESTING.md index c042284a..b2515c84 100644 --- a/TESTING.md +++ b/TESTING.md @@ -404,7 +404,7 @@ The manifest is the machine-readable feature inventory — agents can query it t ### Running the conformance suite ```bash -# Via pytest (parametrized — 1,070 tests) +# Via pytest (parametrized — 1,080 tests) pytest tests/test_conformance.py -v # Via standalone script (used in CI and pre-commit) diff --git a/spec/09-standard-library.md b/spec/09-standard-library.md index 7db80ebf..c8d86ed5 100644 --- a/spec/09-standard-library.md +++ b/spec/09-standard-library.md @@ -2155,7 +2155,26 @@ Decimal is an opaque built-in type implemented via host imports, following the s | `decimal_to_string(d)` | `(Decimal) → String` | String representation | | `decimal_to_float(d)` | `(Decimal) → Float64` | Potentially lossy conversion to float | -**`decimal_from_string` grammar:** both runtimes accept exactly the language `[+-]? ( digits ( "." digits? )? | "." digits ) ( ("e" | "E") [+-]? digits )?` where `digits` is one or more ASCII `0`–`9`, applied after ignoring surrounding whitespace — the six code points `is_whitespace` names (`0x09`, `0x0A`, `0x0B`, `0x0C`, `0x0D`, `0x20`) and no others, stated here because the two host libraries' own trim functions disagree about the rest in both directions, and the exponent token (when present) MUST satisfy `|exp| <= 999999` — the default context's exponent floor, cited by the `decimal_round` fallback below and chosen to keep operand magnitudes bounded and the exponent-token check exact. Only finite decimals are accepted: special values (`NaN`, `sNaN`, `Infinity`), digit-group underscores (`1_000`), non-ASCII digits, and out-of-range exponent tokens are all rejected with `None`, even where a host decimal library would accept them. The accepted domain is defined by this grammar rather than inherited from whatever the host library parses (DESIGN.md: explicit over implicit) — the Python host pre-validates with this grammar before constructing a `decimal.Decimal`, and the browser runtime's parser recognises the same language, checking the exponent token as a string before any numeric conversion (an unbounded token would otherwise round silently above 2^53). This `|exp| <= 999999` bound constrains **input literals** only; exact arithmetic on accepted operands can grow the exponent past it — `decimal_mul(decimal_from_string("1e999999"), decimal_from_string("1e999999"))` yields `1E+1999998` — and such results are computed and rendered identically in both runtimes (the Python host runs the binary operations in a context whose exponent range is widened to the library maximum, `±10^18`, so a finite result never overflows and matches the browser's unbounded engine). +**`decimal_from_string` grammar:** both runtimes accept exactly the language `[+-]? ( digits ( "." digits? )? | "." digits ) ( ("e" | "E") [+-]? digits )?` where `digits` is one or more ASCII `0`–`9`, applied after ignoring surrounding whitespace — the six code points `is_whitespace` names (`0x09`, `0x0A`, `0x0B`, `0x0C`, `0x0D`, `0x20`) and no others, stated here because the two host libraries' own trim functions disagree about the rest in both directions, and the exponent token (when present) MUST satisfy `|exp| <= 999999` — the default context's exponent floor, cited by the `decimal_round` fallback below and chosen to keep operand magnitudes bounded and the exponent-token check exact. Only finite decimals are accepted: special values (`NaN`, `sNaN`, `Infinity`), digit-group underscores (`1_000`), non-ASCII digits, and out-of-range exponent tokens are all rejected with `None`, even where a host decimal library would accept them. The accepted domain is defined by this grammar rather than inherited from whatever the host library parses (DESIGN.md: explicit over implicit) — the Python host pre-validates with this grammar before constructing a `decimal.Decimal`, and the browser runtime's parser recognises the same language, checking the exponent token as a string before any numeric conversion (an unbounded token would otherwise round silently above 2^53). This `|exp| <= 999999` bound constrains **input literals** only; exact arithmetic on accepted operands can grow the exponent past it, as squaring the largest accepted literal shows, and such results are computed and rendered identically in both runtimes (the Python host runs the binary operations in a context whose exponent range is widened to the library maximum, `±10^18`, so a finite result never overflows and matches the browser's unbounded engine). + +``` +-- The |exp| <= 999999 bound constrains input LITERALS. Exact +-- arithmetic on accepted operands can carry the exponent past it. +public fn square_of_the_largest_literal(@Unit -> @String) + requires(true) + ensures(true) + effects(pure) +{ + match decimal_from_string("1e999999") { + Some(@Decimal) -> decimal_to_string(decimal_mul(@Decimal.0, @Decimal.0)), + None -> "unreachable: 1e999999 is inside the grammar" + } +} +``` + +Returns `1E+1999998` on both runtimes. `decimal_from_string` yields an +`Option`, so the operand is unwrapped before it reaches +`decimal_mul`, which takes two `Decimal`\ s. **Arithmetic:** diff --git a/vera/README.md b/vera/README.md index 0a3daa10..f1663b12 100644 --- a/vera/README.md +++ b/vera/README.md @@ -599,7 +599,7 @@ The WASM import interface is the portability contract: the compiled `.wasm` bina **GC reachability discipline (JS host side):** JS host functions that allocate multiple WASM heap blocks and hold intermediates in JS locals must root those intermediates on the shadow stack — otherwise EAGER_GC (and, under pressure, normal GC) reclaims them mid-walk. The runtime exports two helpers: `gcShadowPush(ptr)` writes a pointer to `$gc_sp` and advances it (throws if `$gc_sp` / `$gc_stack_limit` aren't exported, since that means the module was built without GC support but is calling allocators that can trigger GC), and `gcGuard(fn)` saves `$gc_sp` at entry and restores it on exit (success or exception). This is the browser parallel of the CLI-side `_ShadowGuard` context manager added in v0.0.158 (#692). The walkers `writeJson` / `writeHtml` and the parsers `json_parse` / `html_parse` wrap their bodies in `gcGuard` and push intermediates (`arrPtr`, `wrapperPtr`, `jsonPtr`) as soon as each is allocated — see `runtime.mjs` for the canonical pattern. Without this, `Map` / `Set` and similar heap-pointer-keyed collections drop values under GC pressure (#708). -**Parity enforcement:** `tests/test_browser.py` runs the examples the browser target can execute — two explicit lists in that file, not the whole `examples/` directory, since an example that reads stdin interactively, uses a refused host family (file IO, `DB`), or does not compile standalone cannot be compared — plus per-binding batteries over the Map/Set/Decimal/Json/Regex/Markdown host imports, through both Python/wasmtime and Node.js/JS-runtime. The two example lists carry different oracles: the examples exporting `main` are run and compared on stdout, while the ones reached as exported functions are called with fixed arguments and compared on the returned value. The per-binding batteries compare stdout. `json_stringify` and `md_render` are compared the same way and additionally against the canonical form the specification states for each (§9.7.1, §9.7.3), because cross-host equality alone would be satisfied by two hosts agreeing on a wrong answer; `md_render` is also asserted stable under re-render and exercised on `MdBlock` values the test *builds*, since several renderer rules are unreachable through `md_parse`, and `json_stringify`'s number rendering is checked differentially against a real `JSON.stringify`. `md_parse` itself is the one shared operation still divergent between the hosts — plain-text run grouping inside a paragraph, and a handful of block markers §9.7.3 does not pin — tracked as [#1301](https://github.com/aallan/vera/issues/1301); the suite pins the shapes the two do agree on. The browser stubs are covered on two different shapes: `IO.read_file` and `IO.write_file` get the same per-host pinning, run through both runtimes against a path that really is readable or writable so the native `Ok` and the browser `Err` are each asserted (a missing file or an unwritable directory would `Err` on both sides and prove nothing), while `IO.read_char` is exercised in Node alone — the module links and the stub's `Err` arm returns `0` — with no native run to compare against. `Inference` and `DB` return `Err` from every browser operation, which is a deliberate platform boundary — the credentials they need would be readable from page source — rather than a divergence awaiting a fix. Pre-commit hooks and CI trigger these tests on any change to the host binding surface. +**Parity enforcement:** `tests/test_browser.py` runs the examples the browser target can execute — two explicit lists in that file, not the whole `examples/` directory, since an example that reads stdin interactively, uses a refused host family (file IO, `DB`), or does not compile standalone cannot be compared — plus per-binding batteries over the Map/Set/Decimal/Json/Regex/Markdown host imports, through both Python/wasmtime and Node.js/JS-runtime. The two example lists carry different oracles: the examples exporting `main` are run and compared on stdout, while the ones reached as exported functions are called with fixed arguments and compared on the returned value. The per-binding batteries compare stdout. `json_stringify` and `md_render` are compared the same way and additionally against the canonical form the specification states for each (§9.7.1, §9.7.3), because cross-host equality alone would be satisfied by two hosts agreeing on a wrong answer; `md_render` is also asserted stable under re-render and exercised on `MdBlock` values the test *builds*, since several renderer rules are unreachable through `md_parse`, and `json_stringify`'s number rendering is checked differentially against a real `JSON.stringify`. The two *parsers* are where the hosts still differ, and each difference is tracked. `md_parse` diverges most widely — plain-text run grouping inside a paragraph, and a handful of block markers §9.7.3 does not pin — tracked as [#1301](https://github.com/aallan/vera/issues/1301). `json_parse` diverges only outside RFC 8259: the reference host accepts `NaN` and the infinities where the browser refuses them ([#1306](https://github.com/aallan/vera/issues/1306)), and a lone-surrogate escape becomes U+FFFD in the browser where the reference host fails outright ([#1308](https://github.com/aallan/vera/issues/1308)) — every well-formed document parses the same on both. For both parsers the suite pins the inputs the two do agree on. The browser stubs are covered on two different shapes: `IO.read_file` and `IO.write_file` get the same per-host pinning, run through both runtimes against a path that really is readable or writable so the native `Ok` and the browser `Err` are each asserted (a missing file or an unwritable directory would `Err` on both sides and prove nothing), while `IO.read_char` is exercised in Node alone — the module links and the stub's `Err` arm returns `0` — with no native run to compare against. `Inference` and `DB` return `Err` from every browser operation, which is a deliberate platform boundary — the credentials they need would be readable from page source — rather than a divergence awaiting a fix. Pre-commit hooks and CI trigger these tests on any change to the host binding surface. `browser/emit.py` provides `emit_browser_bundle()` for the `vera compile --target browser` CLI command, which produces a ready-to-serve directory (module.wasm + vera-runtime.mjs + index.html). From 4eedb206dfc10006dadd128486334d50e4c23257 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 08:41:49 +0100 Subject: [PATCH 19/69] Ask the two namespace questions with two tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codegen was answering "whose declaration is this bare name?" over `set(_fn_sigs.keys())` — every symbol the whole compilation absorbed — where the checker answers it over a lexical scope. One rule, two tables, and only one of them a scope. `_known_fns` keeps the flat registry for `_translate_call`'s guard rail, which asks whether a RESOLVED target has an implementation and is flat by nature. A new `_scoped_fns` carries the names visible in the compiling declaration's lexical scope, and that is what the #1284 ownership predicate reads. It is `_fn_sigs` narrowed — the comprehension iterates the registry, so the change can only withdraw a name the flat table wrongly claimed — to the namespace's own declarations, the public in-filter names of the imports THAT namespace makes, the prelude, and the `where` helpers of every enclosing function. Every `$`-bearing key is admitted unconditionally: `$` is outside LOWER_IDENT, so a mangled name is never what a bare source call spells. Three consumers ask that question. Instantiation DISCOVERY (`MonoContext.fn_names`) types a bare call to name the clone, and its table is program-wide because the guard rail needs it complete, so `idg(get(()))` beside an invisible `fn get` discovered `idg` where the checker had typed the cell. Discovery now enters the namespace of the declaration it walks — accumulating each function's own `where` helpers as it descends, exactly as it already accumulates `forall` binders. Behind it the call-rewrite's clone-naming override, which beats the general inference for #899's benefit, read the same flat return-type registry and is gated on the same predicate. BOTH sides enter that scope, from one shared derivation, at all TEN walks between them — six on codegen's side and four on the verifier's: seed, hoisted-clone rescan, clone worklist, the `where`-family discovery LEAF, shadowed-sibling scan, and normal-closure chase. An instrumented audit over the whole conformance corpus plus the two module fixtures that reach the shadowed walks reports every entry into the scoped region and zero unscoped ones, which is how the count is known rather than counted. Narrowing one and not the other would leave a clone verified that nobody emits, and the two did NOT agree when this was first written: the verifier built its tables after `inject_prelude` and codegen before, so the entry namespace differed by the five combinators on every module-using program, and the two keyed clone origins differently, so one clone was walked in the entry's namespace on one side and its module's on the other. The prelude is now an explicit input to the derivation rather than read off whichever program the caller holds, the origin lookup goes through the walk that handles `$where$` chains, and a shadowed clone — which reaches those walks under its pre-rename name, in no origin registry — takes its path from the caller. `test_discovery_scopes_agree_between_the_two_sides` compares the two per declaration rather than trusting a comment. Module scope would not have been enough for any of it. A `forall` parent's `where` helper keeps a bare `_fn_sigs` key beside its clone-qualified one, and it IS in the module — only the lexical rule takes it out of a sibling's scope while leaving it in its parent's, which the emitted template body pins. The same visibility walk answers #1281's question, so it is derived once: which bare names a namespace could resolve to more than one module's declaration. E608 now reads the #1274 ownership classification and fires only where the pair can really collide — either declaration not a top-level generic, both owning the bare name, or some namespace able to name both. A qualified-only generic also stops injecting a bare `_fn_sigs` or `_fn_ret_type_exprs` entry; that is defence in depth over four per-NAME consumers rather than what closes the shape, and it is pinned structurally, one cell per table. Closes #1299 Closes #1281 Co-Authored-By: Claude --- tests/codegen_helpers.py | 27 + tests/module_fixture_helpers.py | 94 +- tests/test_lexical_fn_scope_1299.py | 1535 +++++++++++++++++++ tests/test_module_generic_collision_1281.py | 717 +++++++++ tests/test_monomorphize_differential.py | 402 +++++ vera/checker/core.py | 18 +- vera/codegen/closures.py | 12 + vera/codegen/core.py | 188 ++- vera/codegen/functions.py | 10 + vera/codegen/modules.py | 171 ++- vera/codegen/monomorphize.py | 103 +- vera/monomorphize.py | 200 ++- vera/slots.py | 66 +- vera/verifier.py | 137 +- vera/wasm/calls.py | 6 +- vera/wasm/context.py | 57 +- vera/wasm/inference.py | 17 +- 17 files changed, 3644 insertions(+), 116 deletions(-) create mode 100644 tests/test_lexical_fn_scope_1299.py create mode 100644 tests/test_module_generic_collision_1281.py diff --git a/tests/codegen_helpers.py b/tests/codegen_helpers.py index 7b7a1e75..88fd2880 100644 --- a/tests/codegen_helpers.py +++ b/tests/codegen_helpers.py @@ -64,6 +64,33 @@ def wat_calls(wat: str, symbol: str) -> bool: ) is not None +def wat_fn_body(wat: str, name: str) -> str: + """The emitted ``(func $name …)`` block, alone. + + :func:`wat_calls` over a whole module answers "does ANY body call this?", + which is the wrong question whenever the property under test is + per-function — a scope rule, most obviously (#1299): a module's own body + may legitimately call its private ``$get`` in the same WAT where the + importer's body must not. A module-wide assertion cannot separate those + two, and reads as if it had. + + Raises rather than returning ``""`` for an absent function: an empty + string makes every ``not wat_calls(...)`` assertion pass vacuously, which + is precisely the failure this helper exists to prevent. + """ + match = re.search( + rf"(?m)^\s*\(func \${re.escape(name)}{_WAT_NAME_TAIL}", wat, + ) + if match is None: + raise AssertionError( + f"no `(func ${name} …)` in the emitted WAT — the function was " + f"never emitted, so an assertion about its body would be vacuous" + ) + rest = wat[match.end():] + nxt = re.search(r"(?m)^\s*\(func \$", rest) + return wat[match.start():match.end() + (nxt.start() if nxt else len(rest))] + + def exceptions_engine() -> wasmtime.Engine: """A wasmtime engine configured the way ``execute()`` configures its own. diff --git a/tests/module_fixture_helpers.py b/tests/module_fixture_helpers.py index 15f96595..f60aa86b 100644 --- a/tests/module_fixture_helpers.py +++ b/tests/module_fixture_helpers.py @@ -38,15 +38,34 @@ One of the consolidated copies did not unlink at all, and leaked one temp file per fixture it built. + +Beside the two ``ResolvedModule`` builders sits the WHOLE-PIPELINE pair +:func:`build_multi_module` and :func:`module_value` (#1299): write a set +of ``.vera`` files into a directory, resolve / check / verify / compile +the entry exactly as ``vera run`` does, and then execute an export. They +exist because a cross-module namespace bug is only visible as a +DIFFERENTIAL — the verify verdict beside the runtime value, asserted in +one place — and a test that stops at ``compile`` cannot see the half of +the defect that lands at run. Both #1274's and #1299's matrices drive +them, so the two issues' cells can never be built against subtly +different pipelines. """ from __future__ import annotations import tempfile from pathlib import Path +import wasmtime + +from vera.checker import typecheck_with_artifacts +from vera.codegen import compile as codegen_compile +from vera.codegen import execute +from vera.codegen.api import CompileResult from vera.parser import parse_file, parse_to_ast -from vera.resolver import ResolvedModule +from vera.resolver import ModuleResolver, ResolvedModule +from vera.runtime.traps import WasmTrapError from vera.transform import transform +from vera.verifier import verify def resolved_module(path: tuple[str, ...], source: str) -> ResolvedModule: @@ -105,3 +124,76 @@ def fake_resolved_module( program=parse_to_ast(source), source=source, ) + + +def build_multi_module( + tmp_path: Path, files: dict[str, str], + main_name: str = "main.vera", +) -> tuple[list[str], CompileResult, list[str]]: + """Resolve + check + verify + compile *main_name* as ``vera run`` does. + + Returns ``(verify_errors, compile_result, codegen_errors)`` — the two + diagnostic streams a cross-module namespace defect can land in, kept + separate so a caller can assert them independently and, more + importantly, assert them TOGETHER with the runtime value from + :func:`module_value`. A clean verify beside a wrong (or absent) + answer is exactly the false Tier-1 shape these matrices hunt, and it + is invisible to any test that inspects one side alone. + + Type-check errors raise instead of being returned: every caller's + fixture is check-green by construction, so a check error means the + FIXTURE is broken, not the compiler — and returning it would let a + matrix cell pass vacuously with nothing compiled. + + Resolution goes through the real :class:`~vera.resolver.ModuleResolver` + rooted at *tmp_path*, so each module's ``direct`` flag is the + production one (a transitive-only module is marked as such) rather + than a hand-built fixture's default. + """ + tmp_path.mkdir(parents=True, exist_ok=True) + for name, src in files.items(): + (tmp_path / name).write_text(src, encoding="utf-8") + main_path = tmp_path / main_name + source = files[main_name] + program = parse_to_ast(source) + resolver = ModuleResolver(_root=tmp_path) + resolved = resolver.resolve_imports(program, main_path) + diags, arts = typecheck_with_artifacts( + program, source, file=str(main_path), resolved_modules=resolved, + collect_module_artifacts=True, + ) + check_errors = [d.description for d in diags if d.severity == "error"] + assert not check_errors, f"typecheck errors: {check_errors}" + vres = verify(program, source, file=str(main_path), + resolved_modules=resolved) + verify_errors = [ + d.description for d in vres.diagnostics if d.severity == "error" + ] + result = codegen_compile( + program, source=source, file=str(main_path), resolved_modules=resolved, + expr_semantic_types=arts.expr_semantic_types, + expr_target_types=arts.expr_target_types, + module_artifacts=arts.module_artifacts, + ) + cg_errors = [ + d.description for d in result.diagnostics if d.severity == "error" + ] + return verify_errors, result, cg_errors + + +def module_value( + result: CompileResult, fn: str = "main", +) -> tuple[str, object]: + """``("ok", value)`` or ``("trap", message)`` for one export. + + A namespace defect surfaces in BOTH shapes — a wrong-but-loadable + value where the colliding cells happen to share a WAT type, and a + load failure where they do not — so the two are returned as one + tagged pair rather than one being raised past the assertion. A cell + that only ever asserts "no trap" would go green on the silent-wrong + answer, which is the more dangerous half. + """ + try: + return "ok", execute(result, fn_name=fn).value + except (WasmTrapError, wasmtime.WasmtimeError, wasmtime.Trap) as exc: + return "trap", str(exc) diff --git a/tests/test_lexical_fn_scope_1299.py b/tests/test_lexical_fn_scope_1299.py new file mode 100644 index 00000000..ddf98b18 --- /dev/null +++ b/tests/test_lexical_fn_scope_1299.py @@ -0,0 +1,1535 @@ +"""#1299: codegen's bare-call ownership table must be the CALL SITE's scope. + +The #1284 ownership predicate (:func:`vera.slots.bare_call_denotes_user_fn`) +is one rule read over two tables. The checker's table is a lexical scope +walk; codegen's was ``set(self._fn_sigs.keys())`` — a FLAT mirror of every +symbol the whole compilation absorbed, including names the compiling body +cannot see. Where the two disagree, codegen lowers a bare ``get(())`` the +checker resolved to a ``State`` operation as a call to some other +declaration entirely. + +Three routes put an invisible name in that flat table, and all three are +check-green: + +* an imported module's **private** ``fn get`` — still compiled in, because + the module's own bodies call it (#1008-class reachability); +* an imported module's **public** ``fn get`` that the importer's selective + import filter excludes; +* a ``where`` helper of a **``forall`` parent**, which keeps a bare + ``_fn_sigs`` key beside its clone-qualified one where a non-generic + parent's helper does not (#991 / #1015 hoist it out of the bare + namespace; the hoist skips generic subtrees). + +How each lands depends on the widths, not on the route: where the invisible +declaration and the cell share a WAT type the module loads and answers the +WRONG value; where they differ it fails to load. The generic-``where`` +route is always loud, for a reason worth keeping — the bare key exists in +the signature table while no bare SYMBOL is emitted (the helper is only ever +``holder$Bool$where$get``), so the call dies at WAT assembly. + +**Every expected value below is the CHECKER's answer**, and the checker's +answer is *proven* rather than assumed: the invisible ``get`` returns +``@Bool`` in the oracle fixtures while the caller returns ``@Int`` from +``get(())`` and checks green, which is only possible if the checker typed +the call from the ``State`` cell. Each route also carries a rename +control — the same program with the invisible declaration renamed — which +must produce the identical answer, so a case that stops distinguishing the +two tables fails loudly instead of passing vacuously. + +The controls in the other direction matter as much: a VISIBLE imported +``get`` (public, in-filter, direct) still owns the bare name, and a LOCAL +``fn get`` still shadows the operation — that is #1284, and narrowing the +table must not undo it. +""" + +from __future__ import annotations + +import os +import traceback +from pathlib import Path +from typing import ClassVar + +import pytest + +from tests.codegen_helpers import wat_calls, wat_fn_body +from tests.module_fixture_helpers import build_multi_module, module_value +from vera import ast +from vera.codegen.core import CodeGenerator +from vera.monomorphize import MonoContext, Monomorphizer, NamespaceFnNames +from vera.parser import parse_to_ast +from vera.wasm import StringPool +from vera.wasm.context import WasmContext + +# Three values that cannot coincide. CELL is what the checker's answer is +# in every invisible-import cell; LIB is the invisible declaration's own +# answer (what the flat table produced pre-fix); LOCAL is the importer's own +# `fn get`, for the #1284 shadowing control. +CELL = 42007 +LIB = 7007 +LOCAL = 555 + + +# --------------------------------------------------------------------- +# Fixture builders +# --------------------------------------------------------------------- + +def _lib(vis: str, *, name: str = "get", ret: str = "Int") -> str: + """A module declaring `` fn `` plus a public caller of it. + + ``touch`` exists so the declaration is REACHABLE from the module's own + body: that is what keeps a private one compiled into the importer's flat + WASM module, which is the whole premise of the private route. + """ + answer = {"Int": str(LIB), "Bool": "true", "Nat": "3"}[ret] + return f"""\ +module lib; + +{vis} fn {name}(@Unit -> @{ret}) + requires(true) + ensures(true) + effects(pure) +{{ {answer} }} + +public fn touch(@Unit -> @{ret}) + requires(true) + ensures(true) + effects(pure) +{{ {name}(()) }} +""" + + +_LOCAL_GET = f"""\ +private fn get(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{{ {LOCAL} }} +""" + + +def _importer(import_lines: str, *, local_get: bool = False) -> str: + """An importer whose ``main`` reads a ``State`` cell by bare ``get``. + + *import_lines* is the whole import block (possibly empty). Imports must + precede every declaration, so it is threaded rather than spliced in by a + caller — a fixture that produced an unparseable program would fail for a + reason that has nothing to do with the table under test. + """ + local = _LOCAL_GET + "\n" if local_get else "" + return f"""\ +{import_lines}{local}public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{{ + handle[State](@Int = {CELL}) {{ + get(@Unit) -> {{ resume(@Int.0) }}, + put(@Int) -> {{ resume(()) }} + }} in {{ + get(()) + }} +}} +""" + + +def _main(imports: str | None, *, local_get: bool = False) -> str: + """:func:`_importer` importing ``lib`` under the given filter suffix. + + ``None`` means no import at all — the standalone oracle. + """ + return _importer( + "" if imports is None else f"import lib{imports};\n\n", + local_get=local_get, + ) + + +# The oracle: `main` with no import at all. Whatever an import shape does to +# the flat namespace, this is the answer the source commits to — the cell's. +_STANDALONE = _main(None) +_STANDALONE_SHADOWED = _main(None, local_get=True) + + +def _answer( + tmp_path: Path, files: dict[str, str], fn: str = "main", +) -> object: + """Verify + compile + run in one call, asserting the two agree. + + Returns the runtime value. A clean verify beside a trap or a wrong value + is the divergence this issue is about, so both halves are asserted here + rather than in sibling tests that could pass independently. + """ + verify_errors, result, cg_errors = build_multi_module(tmp_path, files) + assert not cg_errors, f"codegen errors: {cg_errors}" + assert not verify_errors, f"verify errors: {verify_errors}" + kind, payload = module_value(result, fn) + assert kind == "ok", f"module did not load/run: {payload}" + return payload + + +# --------------------------------------------------------------------- +# The oracle +# --------------------------------------------------------------------- + +class TestStandaloneOracle: + """What the importer's own source commits to, with no module in view.""" + + def test_no_import_answers_the_cell(self, tmp_path: Path) -> None: + assert _answer(tmp_path, {"main.vera": _STANDALONE}) == CELL + + def test_local_get_shadows_the_operation(self, tmp_path: Path) -> None: + """#1284, restated as this fix's floor: a LOCAL declaration owns the + bare name, and narrowing the table must not take that away.""" + assert _answer( + tmp_path, {"main.vera": _STANDALONE_SHADOWED}, + ) == LOCAL + + +# --------------------------------------------------------------------- +# Route 1 — a private declaration in an imported module +# --------------------------------------------------------------------- + +class TestPrivateImportRoute: + """``private fn get`` in an imported module: invisible to the importer, + still compiled in, and bare-keyed in ``_fn_sigs``.""" + + def test_checker_types_the_call_from_the_cell( + self, tmp_path: Path, + ) -> None: + """The TYPE ORACLE, and the reason every expected value below is CELL. + + The module's invisible ``get`` returns ``@Bool``; ``main`` returns + ``@Int`` from ``get(())``. A program in which the checker had + resolved the call to that declaration could not type-check, so a + clean check is a proof the checker typed the call from the + ``State`` cell — not an assumption about it. + """ + verify_errors, result, cg_errors = build_multi_module( + tmp_path, + {"lib.vera": _lib("private", ret="Bool"), + "main.vera": _main("(touch)")}, + ) + assert not cg_errors, f"codegen errors: {cg_errors}" + assert not verify_errors, f"verify errors: {verify_errors}" + + def test_answers_the_cell(self, tmp_path: Path) -> None: + assert _answer( + tmp_path, + {"lib.vera": _lib("private"), "main.vera": _main("(touch)")}, + ) == CELL + + def test_lowers_to_the_state_import_not_the_module_body( + self, tmp_path: Path, + ) -> None: + """The dispatch itself, so a value that happened to coincide could + not carry the assertion. + + Asserted on ``main``'s body ALONE, because the module's own ``touch`` + legitimately calls ``$get`` in the same WAT — the narrowing is + per-namespace, not a deletion, and a module-wide assertion could not + tell those two apart. + """ + _, result, _ = build_multi_module( + tmp_path, + {"lib.vera": _lib("private"), "main.vera": _main("(touch)")}, + ) + main_body = wat_fn_body(result.wat, "main") + assert wat_calls(main_body, "vera.state_get_Int") + assert not wat_calls(main_body, "get") + # The module's own body is the control: its private helper is still + # in ITS scope, so that call must survive untouched. + assert wat_calls(wat_fn_body(result.wat, "touch"), "get") + + def test_rename_control_answers_the_same(self, tmp_path: Path) -> None: + """The same program with the invisible declaration renamed. It must + answer identically; if it does not, the fixture stopped isolating the + name collision and every cell above is measuring something else.""" + assert _answer( + tmp_path, + {"lib.vera": _lib("private", name="gettt"), + "main.vera": _main("(touch)")}, + ) == CELL + + def test_wildcard_import_does_not_expose_a_private_name( + self, tmp_path: Path, + ) -> None: + assert _answer( + tmp_path, + {"lib.vera": _lib("private"), "main.vera": _main("")}, + ) == CELL + + +# --------------------------------------------------------------------- +# Route 2 — a public declaration the import filter excludes +# --------------------------------------------------------------------- + +class TestSelectiveImportRoute: + """``public fn get`` that the importer's filter does not name.""" + + def test_checker_types_the_call_from_the_cell( + self, tmp_path: Path, + ) -> None: + verify_errors, result, cg_errors = build_multi_module( + tmp_path, + {"lib.vera": _lib("public", ret="Bool"), + "main.vera": _main("(touch)")}, + ) + assert not cg_errors, f"codegen errors: {cg_errors}" + assert not verify_errors, f"verify errors: {verify_errors}" + + def test_excluded_public_name_answers_the_cell( + self, tmp_path: Path, + ) -> None: + assert _answer( + tmp_path, + {"lib.vera": _lib("public"), "main.vera": _main("(touch)")}, + ) == CELL + + def test_included_public_name_still_owns_the_bare_call( + self, tmp_path: Path, + ) -> None: + """The control in the other direction: name it in the filter and it + IS visible, so the checker resolves the import and codegen must + follow. This cell must stay LIB — the fix narrows the table to the + call site's scope, it does not empty it.""" + assert _answer( + tmp_path, + {"lib.vera": _lib("public"), + "main.vera": _main("(touch, get)")}, + ) == LIB + + def test_wildcard_import_exposes_a_public_name( + self, tmp_path: Path, + ) -> None: + assert _answer( + tmp_path, + {"lib.vera": _lib("public"), "main.vera": _main("")}, + ) == LIB + + +# --------------------------------------------------------------------- +# Route 3 — a `where` helper of a generic parent +# --------------------------------------------------------------------- + +_GENERIC_WHERE = f"""\ +private forall fn holder(@T -> @Int) + requires(true) + ensures(true) + effects(pure) +{{ {{HELPER}}(()) }} +where {{ + fn {{HELPER}}(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) + {{ {LIB} }} +}} + +public fn sibling(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{{ + handle[State](@Int = {CELL}) {{ + get(@Unit) -> {{ resume(@Int.0) }}, + put(@Int) -> {{ resume(()) }} + }} in {{ + get(()) + }} +}} + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{{ holder(true) + sibling(()) }} +""" + + +def _generic_where(helper: str) -> str: + return _GENERIC_WHERE.replace("{HELPER}", helper) + + +class TestGenericWhereHelperRoute: + """No imports at all: a ``forall`` parent's helper named ``get`` is in + the MODULE but not in a sibling's lexical scope, so "names visible at + module scope" would not close this route — only the lexical rule does. + + ``main`` calls BOTH, so the generic's clone is instantiated: the two + directions of the narrowing (the sibling must lose the name, the parent + must keep it) are exercised by one compilation. + """ + + def test_sibling_answers_the_cell(self, tmp_path: Path) -> None: + assert _answer( + tmp_path, {"main.vera": _generic_where("get")}, "sibling", + ) == CELL + + def test_rename_control_answers_the_same(self, tmp_path: Path) -> None: + assert _answer( + tmp_path, {"main.vera": _generic_where("gettt")}, "sibling", + ) == CELL + + def test_the_generic_still_reaches_its_own_helper( + self, tmp_path: Path, + ) -> None: + """The parent's own body is the case the narrowing must NOT break: + the helper IS in ``holder``'s scope, so its clone keeps calling the + per-clone symbol — and ``main``'s sum separates the two answers.""" + _, result, cg_errors = build_multi_module( + tmp_path, {"main.vera": _generic_where("get")}, + ) + assert not cg_errors, f"codegen errors: {cg_errors}" + assert wat_calls(result.wat, "holder$Bool$where$get") + assert module_value(result) == ("ok", LIB + CELL) + + +# --------------------------------------------------------------------- +# The same table, read by MONOMORPHIZATION DISCOVERY +# --------------------------------------------------------------------- + +def _generic_wrapped(imports: str, cell: int = CELL) -> str: + """An importer whose bare ``get(())`` is an ARGUMENT to a local generic. + + The wrapping is what reaches the third consumer. A bare ``get(())`` in + value position is typed by ``_translate_call``'s dispatch, which the + scoped table already gates; as a generic's argument it is ALSO typed by + instantiation discovery, to name the clone — and discovery's table is + program-wide, so it kept claiming the invisible declaration. + """ + return f"""\ +import lib{imports}; + +private forall fn idg(@T -> @T) + requires(true) + ensures(true) + effects(pure) +{{ @T.0 }} + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{{ + handle[State](@Int = {cell}) {{ + get(@Unit) -> {{ resume(@Int.0) }}, + put(@Int) -> {{ resume(()) }} + }} in {{ + idg(get(())) + }} +}} +""" + + +class TestDiscoveryLeg: + """The fifth consumer: ``MonoContext.fn_names``, read by the + monomorphizer's instantiation-discovery walk. + + The two dispatch gates read a per-declaration scope; discovery read + ``frozenset(_fn_sigs)`` — the flat registry, which the guard rail needs + complete and which therefore still holds an imported module's private + ``get``. Wrapping the call in a local generic makes discovery name the + clone: from the invisible declaration's declared return (``idg$Bool``) + where the checker had typed the ``State`` cell (``idg$Int``). + + Two tables had to move for this, and the second is the one that bites + after the first: with discovery corrected, the WASM call-rewrite's + clone-naming override (``_declared_return_clone_name``, which BEATS the + general inference for #899's benefit) still read the invisible + declaration's return and named ``idg$Bool`` at a call site whose clone + was now ``idg$Int`` — the module compiled with ``main`` dropped [E620] + instead of failing to load. Both are gated on the same predicate over + the same scope. + + How each landed before is a property of the widths, as everywhere else in + this file: ``@Bool`` against an ``Int`` cell fails to load, ``@Nat`` + reaches a live clone of the wrong signedness and traps. + """ + + def test_private_import_does_not_name_the_clone( + self, tmp_path: Path, + ) -> None: + assert _answer( + tmp_path, + {"lib.vera": _lib("private", ret="Bool"), + "main.vera": _generic_wrapped("(touch)")}, + ) == CELL + + def test_excluded_public_import_does_not_name_the_clone( + self, tmp_path: Path, + ) -> None: + assert _answer( + tmp_path, + {"lib.vera": _lib("public", ret="Bool"), + "main.vera": _generic_wrapped("(touch)")}, + ) == CELL + + def test_nat_variant_reaches_the_cell_rather_than_trapping( + self, tmp_path: Path, + ) -> None: + """``@Nat`` and ``Int`` share a machine width, so this shape does not + fail to load — pre-fix it named a live clone of the wrong signedness + and the negative cell value reached it. A negative cell is the whole + point: a non-negative one could not tell the two clones apart.""" + assert _answer( + tmp_path, + {"lib.vera": _lib("private", ret="Nat"), + "main.vera": _generic_wrapped("(touch)", cell=-5)}, + ) == -5 + + def test_rename_control_answers_the_same(self, tmp_path: Path) -> None: + assert _answer( + tmp_path, + {"lib.vera": _lib("private", name="gettt", ret="Bool"), + "main.vera": _generic_wrapped("(touch)")}, + ) == CELL + + def test_a_visible_import_still_names_the_clone( + self, tmp_path: Path, + ) -> None: + """The control in the other direction: name it in the filter and the + call IS that declaration, so its return type must keep naming the + clone. ``string_length`` of it pins the clone that ran — the + ``@String`` instantiation, which the operation's ``Int`` could not + produce.""" + lib = """\ +module lib; + +public fn get(@Unit -> @String) + requires(true) + ensures(true) + effects(pure) +{ "abcd" } +""" + main = f"""\ +import lib(get); + +private forall fn idg(@T -> @T) + requires(true) + ensures(true) + effects(pure) +{{ @T.0 }} + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{{ + handle[State](@Int = {CELL}) {{ + get(@Unit) -> {{ resume(@Int.0) }}, + put(@Int) -> {{ resume(()) }} + }} in {{ + string_length(idg(get(()))) + }} +}} +""" + assert _answer(tmp_path, {"lib.vera": lib, "main.vera": main}) == 4 + + +# --------------------------------------------------------------------- +# The same table, read at the INTRINSIC gate +# --------------------------------------------------------------------- + +_SHOW_LIB = f"""\ +module lib; + +private fn show(@Int -> @Int) + requires(true) + ensures(true) + effects(pure) +{{ {LIB} }} + +public fn touch(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{{ show(1) }} +""" + +_SHOW_MAIN = """\ +import lib(touch); + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ string_length(show(42)) } +""" + + +class TestAbilityOpRoute: + """``_translate_call``'s FIRST use of the table is the intrinsic gate, + not the effect-op dispatch — and it is reachable independently. + + ``show`` / ``hash`` are the ability operations E151 does NOT reserve + (#908), so a module may declare ``fn show`` where it may not declare + ``fn array_length``. A private one bare-keyed the flat table, and the + importer's ``show(42)`` — which the checker resolved to the ability + operation, since the module's declaration is not in its scope — skipped + the ability dispatch and lowered as a call to the module's ``@Int`` + function instead. ``string_length`` of the result then received an i64 + and the module failed to load. + + One rule, one table: narrowing it closes the intrinsic gate and the op + gate together, which is why the predicate is shared rather than + reimplemented at each. + """ + + def test_show_reaches_the_ability_operation( + self, tmp_path: Path, + ) -> None: + # `show(42)` is the String "42", whose length is 2 — a value the + # module's `show` (an @Int) could not produce in this position at + # all, which is what makes the load failure the pre-fix symptom. + assert _answer( + tmp_path, + {"lib.vera": _SHOW_LIB, "main.vera": _SHOW_MAIN}, + ) == 2 + + def test_show_inside_a_lifted_closure_reaches_it_too( + self, tmp_path: Path, + ) -> None: + """A closure body is lexically inside its enclosing function, and is + compiled through a SEPARATE ``WasmContext`` built by the lift. + + That context gets its own copy of the tables, so the scope has to be + carried across the lift or the closure body silently reverts to the + flat one. Reachable through ``show`` and not through ``get``: an + anonymous function's effect clause admits no row, so a closure body + cannot perform an effect operation at all — the ability ops are the + one shadowable name that survives the boundary. + """ + closure_main = """\ +import lib(touch); + +type IntToInt = fn(Int -> Int) effects(pure); + +private fn make(@Unit -> @IntToInt) + requires(true) + ensures(true) + effects(pure) +{ fn(@Int -> @Int) effects(pure) { string_length(show(@Int.0)) } } + +private fn drive(@IntToInt -> @Int) + requires(true) + ensures(true) + effects(pure) +{ apply_fn(@IntToInt.0, 42) } + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ drive(make(())) } +""" + assert _answer( + tmp_path, + {"lib.vera": _SHOW_LIB, "main.vera": closure_main}, + ) == 2 + + +# --------------------------------------------------------------------- +# The visibility matrix +# --------------------------------------------------------------------- + +_TRANSITIVE_MID = """\ +module mid; + +import deep(touch); + +public fn door(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ touch(()) } +""" + + +def _deep(vis: str) -> str: + return _lib(vis).replace("module lib;", "module deep;") + + +class TestVisibilityMatrix: + """visibility x filter x shadowing x reach, every cell asserting the + verify verdict and the runtime value together. + + The expected value is a function of the LANGUAGE rule, never of what + codegen emits: a local declaration wins outright (#1284); otherwise a + module declaration wins exactly when the importer can SEE it — public, + named by the filter (or a wildcard), and reached by a DIRECT import + (spec §8.6.4); otherwise the bare call is the ``State`` operation. + """ + + @pytest.mark.parametrize("local_get", [False, True], ids=["plain", "shadowed"]) + @pytest.mark.parametrize( + ("vis", "imports"), + [ + ("private", "(touch)"), + ("private", ""), + ("public", "(touch)"), + ("public", "(touch, get)"), + ("public", ""), + ], + ids=["priv_filtered", "priv_wildcard", "pub_excluded", + "pub_in_filter", "pub_wildcard"], + ) + def test_direct( + self, tmp_path: Path, vis: str, imports: str, local_get: bool, + ) -> None: + visible = vis == "public" and (imports == "" or "get" in imports) + expected = LOCAL if local_get else (LIB if visible else CELL) + assert _answer( + tmp_path, + {"lib.vera": _lib(vis), + "main.vera": _main(imports, local_get=local_get)}, + ) == expected + + @pytest.mark.parametrize("local_get", [False, True], ids=["plain", "shadowed"]) + @pytest.mark.parametrize("vis", ["private", "public"]) + def test_transitive( + self, tmp_path: Path, vis: str, local_get: bool, + ) -> None: + """A transitive module contributes NOTHING to the importer's + namespace (spec §8.6.4), so even a public ``get`` two hops away + leaves the bare call as the operation.""" + main = _importer("import mid(door);\n\n", local_get=local_get) + assert _answer( + tmp_path, + {"deep.vera": _deep(vis), "mid.vera": _TRANSITIVE_MID, + "main.vera": main}, + ) == (LOCAL if local_get else CELL) + + +# --------------------------------------------------------------------- +# The two tables, as structures +# --------------------------------------------------------------------- + +_NESTED_HELPERS = """\ +private forall fn top(@T -> @Int) + requires(true) + ensures(true) + effects(pure) +{ a(()) } +where { + fn a(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) + { b(()) } + where { + fn b(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) + { 1 } + } + + fn c(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) + { 2 } +} +""" + + +def _top_decl(source: str, name: str) -> ast.FnDecl: + """The named top-level ``FnDecl`` of *source*.""" + for tld in parse_to_ast(source).declarations: + decl = tld.decl + if isinstance(decl, ast.FnDecl) and decl.name == name: + return decl + raise AssertionError(f"no top-level fn {name!r} in the fixture") + + +class TestTableInvariants: + """Properties of the two tables the split created, asserted directly. + + Each is claimed in a docstring in ``vera/codegen/core.py``; a claim in a + docstring is not an assertion, and both of these fail SILENTLY — a scope + admitting a name with no signature, or a helper the scope walk skipped, + changes an answer without changing a symbol. + """ + + def test_scoped_names_never_exceed_the_registry( + self, tmp_path: Path, + ) -> None: + """``_scoped_fn_names`` returns a subset of ``_fn_sigs``. + + The whole change is a NARROWING: it may withdraw a name the flat + table wrongly claimed, never introduce one with no signature behind + it. Asserted over every call made during a compilation that + exercises the routes together — an import, a filter, and a generic + parent's nested helpers — rather than on a constructed input. + """ + seen: list[tuple[set[str], set[str]]] = [] + original = CodeGenerator._scoped_fn_names + + def recording( + gen: CodeGenerator, where_scope: frozenset[str], own_name: str, + ) -> set[str]: + out = original(gen, where_scope, own_name) + seen.append((set(out), set(gen._fn_sigs))) + return out + + CodeGenerator._scoped_fn_names = recording # type: ignore[method-assign] + try: + build_multi_module( + tmp_path, + {"lib.vera": _lib("private"), + "main.vera": ( + _importer("import lib(touch);\n\n") + "\n" + + _NESTED_HELPERS)}, + ) + finally: + CodeGenerator._scoped_fn_names = original # type: ignore[method-assign] + + assert seen, "no function was compiled — the assertion is vacuous" + for scoped, registry in seen: + assert scoped <= registry, ( + f"scoped names outside the registry: {scoped - registry}" + ) + + def test_prelude_names_stay_in_every_scope( + self, tmp_path: Path, + ) -> None: + """The prelude combinators are declarations too, visible everywhere. + + Inert as behaviour today — none of them is named like an operation + or an intrinsic, so withdrawing them changes no emitted call — which + is exactly why the membership is asserted rather than left to a + behaviour test that would be green either way. A prelude addition + that DID collide would otherwise land silently. + """ + seen: list[tuple[set[str], set[str], set[str]]] = [] + original = CodeGenerator._scoped_fn_names + + def recording( + gen: CodeGenerator, where_scope: frozenset[str], own_name: str, + ) -> set[str]: + out = original(gen, where_scope, own_name) + seen.append( + (set(out), set(gen._fn_sigs), set(gen._prelude_fn_names)), + ) + return out + + CodeGenerator._scoped_fn_names = recording # type: ignore[method-assign] + try: + build_multi_module( + tmp_path, + {"lib.vera": _lib("private"), "main.vera": _main("(touch)")}, + ) + finally: + CodeGenerator._scoped_fn_names = original # type: ignore[method-assign] + + assert any(prelude for _, _, prelude in seen), ( + "the prelude registered no function — the assertion is vacuous" + ) + for scoped, registry, prelude in seen: + missing = (prelude & registry) - scoped + assert not missing, f"prelude names withdrawn: {missing}" + + def test_where_scopes_enumerate_the_flatten_walk(self) -> None: + """``_where_fn_scopes`` visits exactly ``_flatten_where_fns``'s + helpers, in the same order. + + Two walks over one tree with one skip rule between them. A helper + only one of them reaches would be compiled against a scope built for + a different function — or not paired at all — so they are compared + rather than read side by side. + """ + top = _top_decl(_NESTED_HELPERS, "top") + flat = CodeGenerator._flatten_where_fns(top) + paired = CodeGenerator._where_fn_scopes(top) + assert [id(f) for f in flat] == [id(w) for w, _ in paired] + assert len(flat) == 3, ( + "the fixture must carry a helper, a NESTED helper, and a " + "sibling, or the walk comparison proves nothing about nesting" + ) + + def test_every_mangled_registry_key_stays_in_scope( + self, tmp_path: Path, + ) -> None: + """The narrowing touches only names a source program can SPELL. + + ``$`` is outside ``LOWER_IDENT``, so a ``$``-bearing registry key is + compiler-minted — a mono clone, a ``mod$…`` reroute, a hoisted + helper — and is never what a bare call in the source wrote. Every + one is admitted unconditionally, so the ownership predicate keeps + answering "user-owned" at the sites that see a name the rewrite + already resolved, and the change is provably confined to the + source-spellable half of the table. + + Asserted directly rather than through behaviour: nothing downstream + currently DEPENDS on a mangled name answering user-owned (the + intrinsic and op branches are all exact-name matches), so a + behaviour test would be green either way and prove nothing. + """ + seen: list[tuple[set[str], set[str]]] = [] + original = CodeGenerator._scoped_fn_names + + def recording( + gen: CodeGenerator, where_scope: frozenset[str], own_name: str, + ) -> set[str]: + out = original(gen, where_scope, own_name) + seen.append((set(out), set(gen._fn_sigs))) + return out + + CodeGenerator._scoped_fn_names = recording # type: ignore[method-assign] + try: + build_multi_module( + tmp_path, {"main.vera": _generic_where("get")}, + ) + finally: + CodeGenerator._scoped_fn_names = original # type: ignore[method-assign] + + mangled = {n for _, reg in seen for n in reg if "$" in n} + assert mangled, ( + "the fixture emitted no mangled symbol — it must monomorphize " + "and hoist, or this assertion is vacuous" + ) + for scoped, registry in seen: + missing = {n for n in registry if "$" in n} - scoped + assert not missing, f"mangled keys withdrawn from scope: {missing}" + + def test_every_emission_door_supplies_the_decl_its_own_helpers( + self, tmp_path: Path, + ) -> None: + """The DOOR invariant, over all four emission sites at once. + + Whatever a declaration's own direct ``where`` helpers are named, the + scope it is compiled under must contain them — a body that cannot + see its own helper is the mirror image of #1299. + + Only Pass 2's top-level loop supplies a non-empty scope today, and + that is not an oversight at the other three: ``_hoist_clone_where_fns`` + strips every clone's helpers into standalone clone-qualified decls, + and ``_register_modules`` runs the #991 hoist and #1014 qualification + over every module AST, so the mono, Pass-2.5 and Pass-2.6 doors + receive declarations whose remaining helpers are all ``$``-qualified + (or absent). Stated as an invariant rather than as three dead + arguments: this goes red the moment any door starts receiving a + declaration with a BARE helper and no scope to match. + + The fixture set covers all four doors — a local generic template + with a nested helper tree, a monomorphized clone, an imported body, + and a ``mod$…``-renamed shadowed one. + """ + seen: list[tuple[str, frozenset[str], frozenset[str]]] = [] + original = CodeGenerator._compile_fn_tracked + + def recording(gen: CodeGenerator, decl, **kw): # type: ignore[no-untyped-def] + seen.append(( + decl.name, + frozenset( + w.name for w in decl.where_fns or () if "$" not in w.name + ), + frozenset(kw.get("where_scope", frozenset())), + )) + return original(gen, decl, **kw) + + CodeGenerator._compile_fn_tracked = recording # type: ignore[method-assign] + try: + build_multi_module( + tmp_path, + {"lib.vera": _lib("public"), + "main.vera": ( + "import lib(touch);\n\n" + + f"private fn touch(@Unit -> @Int)\n" + f" requires(true)\n ensures(true)\n" + f" effects(pure)\n{{ {LOCAL} }}\n\n" + + _t_unused_holder("private") + "\n" + + _NESTED_HELPERS + "\n" + + "public fn main(@Unit -> @Int)\n" + " requires(true)\n ensures(true)\n" + " effects(pure)\n" + "{ top(true) + touch(()) + lib::touch(()) }\n")}, + ) + finally: + CodeGenerator._compile_fn_tracked = original # type: ignore[method-assign] + + assert any("$" in name for name, _, _ in seen), ( + "no mangled symbol was compiled — the mono / mod$ doors were " + "never reached and the invariant is vacuous there" + ) + assert any(scope for _, _, scope in seen), ( + "no declaration got a non-empty scope — the Pass-2 door was " + "never reached with a helper-bearing declaration" + ) + for name, own, scope in seen: + assert own <= scope, ( + f"{name} compiled without its own helpers in scope: " + f"{own - scope}" + ) + + def test_a_grandchild_helper_is_not_in_its_grandparents_scope( + self, + ) -> None: + """The nesting rule, at the one place it differs from a flat union. + + ``b`` is a helper of ``a``, which is a helper of ``top``. The + checker's ``_lookup_function_scoped`` reads each frame's DIRECT + helpers, so ``b`` is in ``a``'s scope and in its own, and in neither + ``top``'s nor its uncle ``c``'s. A scope built as "every helper + anywhere under the top-level declaration" would pass every other + test in this file and reopen #1299 one level down. + """ + top = _top_decl(_NESTED_HELPERS, "top") + scopes = {w.name: s for w, s in CodeGenerator._where_fn_scopes(top)} + assert scopes["a"] == frozenset({"a", "c", "b"}) + assert scopes["b"] == frozenset({"a", "c", "b"}) + assert scopes["c"] == frozenset({"a", "c"}) + + +def _t_unused_holder(vis: str) -> str: + """A ``forall`` whose T is unused, declaring a ``State`` row. + + T-unused so the TEMPLATE compiles as written — a ``@T`` parameter has no + monomorphic WASM type, so such a template is skipped and only clones are + emitted, and clones no longer carry their helpers. The declared row is + what makes the scope OBSERVABLE: under ``effects(pure)`` the op registry + is empty, so a bare ``get`` withdrawn from scope has nothing to divert + to and falls through to the same ordinary call — green either way, and + proving nothing. + """ + return f"""\ +{vis} forall fn holder(@Unit -> @Int) + requires(true) + ensures(true) + effects(>) +{{ get(()) }} +where {{ + fn get(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) + {{ {LIB} }} +}} +""" + + +def _holder_driver(import_lines: str, call: str) -> str: + """An importer whose handled body calls *call* instead of ``get(())``.""" + return _importer(import_lines).replace("get(())", call, 1) + + +class TestGenericTemplateKeepsItsOwnHelper: + """The other direction of route three, at the TEMPLATE rather than a + clone — and the case that makes the ``where_scope`` argument load-bearing. + + A ``forall`` whose T appears in no parameter is compilable as written, + so its template is emitted, un-monomorphized, with its body's bare call + to its own ``where`` helper intact. That helper IS in the template's + lexical scope; drop the scope and the same narrowing that rescues the + sibling reroutes the parent's own call to the ``State`` operation — + the identical defect, mirrored, and returning the cell's value where the + source calls a function that cannot produce it. + + Asserted on the emitted TEMPLATE body, not only on the runtime value. + Monomorphization supersedes the template at every call site — the clone + carries its own per-instantiation copy of the helper, so the template's + WAT is emitted and never reached — and a value assertion alone is + therefore green whatever the template contains. The instruction stream + is where a template compiled against the wrong scope is visible. + + A template is a LOCAL phenomenon: an imported module's generic is + registered per-owner and emitted only as clones, so no imported template + body is ever compiled. That is asserted below rather than assumed, + because it is the reason the Pass-2.5 door needs no scope at all. + """ + + _LIB = "module lib;\n\n" + _t_unused_holder("public") + """ +public fn touch(@Unit -> @Int) + requires(true) + ensures(true) + effects(>) +{ holder(()) } +""" + + def test_local_template_calls_its_helper(self, tmp_path: Path) -> None: + source = _t_unused_holder("private") + "\n" + _holder_driver( + "", "holder(())", + ) + _, result, cg_errors = build_multi_module( + tmp_path, {"main.vera": source}, + ) + assert not cg_errors, f"codegen errors: {cg_errors}" + template = wat_fn_body(result.wat, "holder") + assert wat_calls(template, "get") + assert not wat_calls(template, "vera.state_get_Int") + # And the clone the call site actually reaches answers the same. + assert module_value(result) == ("ok", LIB) + + def test_a_template_helper_sees_its_own_siblings( + self, tmp_path: Path, + ) -> None: + """The same claim one level in: a helper's body resolves against its + SIBLINGS, so the sweep that emits the template's helpers has to give + each one the scope of its ancestors' ``where`` blocks, not an empty + one. ``a`` calls ``get``; both are helpers of the same generic.""" + source = f"""\ +private forall fn holder(@Unit -> @Int) + requires(true) + ensures(true) + effects(>) +{{ a(()) }} +where {{ + fn a(@Unit -> @Int) + requires(true) + ensures(true) + effects(>) + {{ get(()) }} + + fn get(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) + {{ {LIB} }} +}} + +""" + _holder_driver("", "holder(())") + _, result, cg_errors = build_multi_module( + tmp_path, {"main.vera": source}, + ) + assert not cg_errors, f"codegen errors: {cg_errors}" + helper = wat_fn_body(result.wat, "a") + assert wat_calls(helper, "get") + assert not wat_calls(helper, "vera.state_get_Int") + assert module_value(result) == ("ok", LIB) + + def test_an_imported_generic_is_emitted_only_as_clones( + self, tmp_path: Path, + ) -> None: + _, result, cg_errors = build_multi_module( + tmp_path, + {"lib.vera": self._LIB, + "main.vera": _holder_driver( + "import lib(touch);\n\n", "touch(())")}, + ) + assert not cg_errors, f"codegen errors: {cg_errors}" + assert "(func $holder " not in result.wat, ( + "an imported generic template was emitted under its bare name — " + "the Pass-2.5 door would then need the scope the mono door does " + "not" + ) + clone = wat_fn_body(result.wat, "mod$lib$holder$Bool") + assert wat_calls(clone, "mod$lib$holder$Bool$where$get") + assert not wat_calls(clone, "vera.state_get_Int") + assert module_value(result) == ("ok", LIB) + + def test_importer_bare_get_is_still_the_cell( + self, tmp_path: Path, + ) -> None: + """The same module, with the importer keeping its own ``get(())``. + + Neither the module's helper nor its ``holder`` is in the importer's + scope, so this half must stay the operation — the two directions + hold at once, which is the whole claim. + """ + assert _answer( + tmp_path, + {"lib.vera": self._LIB, "main.vera": _main("(touch)")}, + ) == CELL + + +class TestUnscopedContextDefault: + """A ``WasmContext`` built with no ``scoped_fns`` keeps the FLAT answer. + + Every production caller supplies one — ``_compile_fn`` computes it, and + the closure lift carries its parent's — so the default is reached only by + a context constructed directly. It still has to be the right default, + and the two candidates differ in kind rather than in degree: falling back + to ``known_fns`` reproduces the pre-#1299 behaviour, while falling back + to an empty set would say NO name is user-owned and route every bare + ``get`` in such a context to the operation registries — the opposite + error, and one that turns a working program into an unresolved cell + rather than a subtly wrong value. + + Asserted directly on the context, because the distinguishing input is a + construction that no compilation performs. + """ + + def test_default_scope_is_the_flat_registry_not_empty(self) -> None: + ctx = WasmContext(StringPool(), known_fns={"get"}) + assert ctx._scoped_fns == {"get"} + # `known_fns` fallback → the user's declaration owns the name. + # An empty fallback would answer True here. + assert not ctx._bare_call_denotes_op("get") + + def test_an_explicit_scope_still_wins(self) -> None: + ctx = WasmContext( + StringPool(), known_fns={"get", "other"}, scoped_fns={"other"}, + ) + assert ctx._bare_call_denotes_op("get") + assert not ctx._bare_call_denotes_op("other") + + def test_an_explicitly_empty_scope_is_not_confused_with_absent( + self, + ) -> None: + """``scoped_fns=set()`` is a real answer — "this body owns nothing" — + and must not be read as "no scope supplied"; a truthiness test would + collapse the two and hand such a context the flat table.""" + ctx = WasmContext(StringPool(), known_fns={"get"}, scoped_fns=set()) + assert ctx._scoped_fns == set() + assert ctx._bare_call_denotes_op("get") + + +class TestGuardRailKeepsTheFlatTable: + """The other half of the split: ``_known_fns`` must stay flat. + + ``_translate_call``'s guard rail asks whether a RESOLVED target has a + symbol — after mono mangling and ``mod$…`` rerouting — which is a + question about the whole emitted module, not about one namespace. + Narrowing that set too would turn a cross-module call into a + ``CodegenSkip``, so a module-qualified call through a SHADOWED name is + kept here as the live proof it did not happen: the module's body is + emitted as ``mod$lib$touch``, a name in no namespace's source scope. + """ + + _SHADOWED_LIB = f"""\ +module lib; + +public fn touch(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{{ {LIB} }} +""" + + _QUALIFIED_MAIN = f"""\ +import lib(touch); + +private fn touch(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{{ {LOCAL} }} + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{{ lib::touch(()) + touch(()) }} +""" + + def test_qualified_call_still_reaches_the_shadowed_module_body( + self, tmp_path: Path, + ) -> None: + assert _answer( + tmp_path, + {"lib.vera": self._SHADOWED_LIB, + "main.vera": self._QUALIFIED_MAIN}, + ) == LIB + LOCAL + + +class TestDiscoveryScopeIsPerNamespace: + """Discovery enters the namespace of the declaration it is WALKING. + + The seed walk covers the entry program's bodies and every module's, and + they resolve bare names in different scopes. Handing a module's body the + ENTRY's scope is the mirror of the bug this issue is about: the module's + own private declaration becomes invisible to its own code, and discovery + names the clone from the effect operation instead. + + ``idg`` is deliberately PUBLIC and in the importer's filter. A + qualified-only generic's instantiations are re-discovered by the + shadowed-module worklist, which enters the module's namespace by its own + route — so a bare-name-owning generic is the one shape whose clone is + named by the SEED walk alone, and the only one that can tell whether the + seed entered the right namespace. + """ + + _LIB = """\ +module lib; + +private fn get(@Unit -> @Bool) + requires(true) + ensures(true) + effects(pure) +{ true } + +public forall fn idg(@T -> @T) + requires(true) + ensures(true) + effects(pure) +{ @T.0 } + +public fn touch(@Unit -> @Bool) + requires(true) + ensures(true) + effects(>) +{ idg(get(())) } +""" + + _MAIN = f"""\ +import lib(touch, idg); + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{{ + handle[State](@Int = {CELL}) {{ + get(@Unit) -> {{ resume(@Int.0) }}, + put(@Int) -> {{ resume(()) }} + }} in {{ + if touch(()) then {{ 1 }} else {{ 0 }} + }} +}} +""" + + def test_a_module_body_resolves_in_its_own_namespace( + self, tmp_path: Path, + ) -> None: + """``lib``'s ``get`` IS in ``lib``'s scope, so ``idg(get(()))`` there + instantiates at ``Bool`` — the declaration's type, not the cell's. + + ``touch`` returns ``@Bool`` and the importer branches on it, so the + answer distinguishes the two clones: an ``Int`` instantiation could + not have produced a Bool for the ``if`` at all. + """ + assert _answer( + tmp_path, {"lib.vera": self._LIB, "main.vera": self._MAIN}, + ) == 1 + + +class TestDiscoveryWalkContract: + """Two properties of the discovery walk asserted on the walk itself. + + Neither is reachable from a compilation today — every declaration that + reaches ``collect_calls_in_node`` has had its bare helper names hoisted + or stripped, and a ``$``-bearing call name is never an operation's — so a + behaviour test would be green either way and prove nothing. They are the + walk's contract all the same, and they mirror ``_scoped_fn_names``'s two + on the codegen side, so they are asserted where they live. + """ + + @staticmethod + def _mono(scope: frozenset[str]) -> Monomorphizer: + ctx = MonoContext( + generic_decls={}, ctor_to_adt={}, ctor_tp_indices={}, + adt_tp_counts={}, type_aliases={}, type_alias_params={}, + fn_ret_types={}, + fn_names=frozenset({"get", "holder", "holder$Bool$where$get"}), + namespace_fn_names=NamespaceFnNames({None: scope}, frozenset()), + ) + return Monomorphizer(ctx) + + def test_a_mangled_name_stays_user_owned_in_any_scope(self) -> None: + mono = self._mono(frozenset({"holder"})) + with mono.namespace_scope(None): + assert mono._bare_call_is_user_fn("holder$Bool$where$get") + # …while the bare spelling of the same helper does not. + assert not mono._bare_call_is_user_fn("get") + + def test_a_walked_declarations_own_helpers_join_its_scope(self) -> None: + """A bare helper name is in its parent's scope while that parent's + body is being walked, and out of it again afterwards.""" + source = """\ +private fn holder(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ get(()) } +where { + fn get(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) + { 1 } +} +""" + holder = _top_decl(source, "holder") + mono = self._mono(frozenset({"holder"})) + seen: list[bool] = [] + original = Monomorphizer._collect_calls_in_node_scoped + + def recording(inner, fn, *a): # type: ignore[no-untyped-def] + seen.append(inner._bare_call_is_user_fn("get")) + return original(inner, fn, *a) + + Monomorphizer._collect_calls_in_node_scoped = recording # type: ignore[method-assign] + try: + with mono.namespace_scope(None): + assert not mono._bare_call_is_user_fn("get") + mono.collect_calls_in_node(holder, {}, {}, {}) + assert not mono._bare_call_is_user_fn("get") + finally: + Monomorphizer._collect_calls_in_node_scoped = original # type: ignore[method-assign] + + assert seen and seen[0], ( + "the helper was not in its own parent's walk scope" + ) + + +# --------------------------------------------------------------------- +# The helper-family leaf — the walk BOTH sides drive directly +# --------------------------------------------------------------------- + +_HELPER_FAMILY_LIB = """\ +module lib; + +private fn get(@Unit -> @Bool) + requires(true) + ensures(true) + effects(pure) +{ true } + +public fn touch(@Unit -> @Bool) + requires(true) + ensures(true) + effects(pure) +{ get(()) } +""" + +_HELPER_FAMILY_MAIN = f"""\ +import lib(touch); + +private forall fn outer(@T -> @Int) + requires(true) + ensures(true) + effects(pure) +{{ ginner(@T.0) }} +where {{ + forall fn ginner(@U -> @Int) + requires(true) + ensures(true) + effects(pure) + {{ + handle[State](@Int = {CELL}) {{ + get(@Unit) -> {{ resume(@Int.0) }}, + put(@Int) -> {{ resume(()) }} + }} in {{ + gsib(get(())) + }} + }} + + forall fn gsib(@V -> @Int) + requires(true) + ensures(true) + effects(pure) + {{ 7 }} +}} + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{{ outer(1) }} +""" + + +class TestNestedHelperFamilyLeaf: + """``collect_generic_helper_instances`` — the discovery leaf under a + generic parent's ``where`` family, and the one walk BOTH sides drive + directly rather than through their own loops. + + Left unscoped it fell back to the flat table on codegen AND the verifier + at once, so the two AGREED — and a differential cannot see two sides + being wrong together. What it produced was a regression this branch + introduced: ``gsib(get(()))`` inside the helper typed its argument from + the invisible module's ``@Bool`` return, discovering ``gsib``, + while the (already scoped) WASM rewrite named ``gsib$Int`` from the + ``State`` cell. Nothing emitted matched, ``ginner``'s clone was + skipped [E602], and ``main`` was dropped from a program that + ``vera check`` and ``vera verify`` both passed — 8 obligations verified + against a module with no ``main`` in it. + + So the pin is anchored on the CHECKER, not on the other side: the + checker resolves that bare ``get(())`` to the ``State`` + operation, so ``Int`` is the type argument, and the emitted clone must + be the one the checker's answer names. + """ + + _FILES: ClassVar[dict[str, str]] = { + "lib.vera": _HELPER_FAMILY_LIB, "main.vera": _HELPER_FAMILY_MAIN, + } + + def test_the_helper_family_instantiates_at_the_checkers_type( + self, tmp_path: Path, + ) -> None: + """``gsib`` is cloned at ``Int`` — the cell's type — not ``Bool``. + + The assertion is on the emitted SYMBOL rather than only on the + value, because the value alone cannot distinguish "named the right + clone" from "named the wrong one and got dropped": a dropped `main` + has no value at all. + """ + _, result, cg_errors = build_multi_module( + tmp_path, dict(self._FILES), + ) + assert not cg_errors, f"codegen errors: {cg_errors}" + assert "(func $outer$Int$where$gsib$Int" in result.wat, ( + f"the helper family was instantiated at the wrong type; emitted " + f"symbols: " + f"{sorted(n for n in result.wat.split() if n.startswith('$outer'))}" + ) + assert "$where$gsib$Bool" not in result.wat, ( + "a clone was named from the invisible module declaration's " + "return type" + ) + + def test_it_runs_and_answers_the_helper(self, tmp_path: Path) -> None: + assert _answer(tmp_path, dict(self._FILES)) == 7 + + def test_rename_control_answers_the_same(self, tmp_path: Path) -> None: + """The identical importer with only the module's declaration renamed. + + Base compiles and runs BOTH spellings; the op spelling is what this + branch broke, so the control is what proves the fixture isolates the + name collision rather than the helper family itself. + """ + assert _answer(tmp_path, { + "lib.vera": _HELPER_FAMILY_LIB.replace("get", "gettt"), + "main.vera": _HELPER_FAMILY_MAIN, + }) == 7 + + def test_no_discovery_walk_runs_without_a_namespace_scope( + self, tmp_path: Path, + ) -> None: + """The door invariant, over every walk this compilation enters. + + The equality differential compares the two sides against each other, + so a walk both sides leave unscoped is invisible to it. This asks a + question neither side can answer wrongly in agreement: while the + context carries visibility tables, no entry into the scoped region + may run with no scope entered. A future walk added without one + fails here, naming its call site. + """ + unscoped: dict[str, int] = {} + originals = { + name: getattr(Monomorphizer, name) + for name in ( + "collect_calls_in_node", + "collect_calls_in_expr", + "collect_generic_helper_instances", + ) + } + + def wrap(original): # type: ignore[no-untyped-def] + def probe(inner, *a, **kw): # type: ignore[no-untyped-def] + if (inner.ctx.namespace_fn_names is not None + and inner._scope_fn_names is None): + site = "?" + for frame in reversed(traceback.extract_stack()[:-2]): + if frame.filename.endswith( + f"vera{os.sep}monomorphize.py", + ): + continue + site = f"{Path(frame.filename).name}:{frame.lineno}" + break + unscoped[site] = unscoped.get(site, 0) + 1 + return original(inner, *a, **kw) + return probe + + for name, original in originals.items(): + setattr(Monomorphizer, name, wrap(original)) + try: + build_multi_module(tmp_path, dict(self._FILES)) + finally: + for name, original in originals.items(): + setattr(Monomorphizer, name, original) + + assert not unscoped, ( + f"discovery walks ran with no namespace scope entered: " + f"{sorted(unscoped)}" + ) diff --git a/tests/test_module_generic_collision_1281.py b/tests/test_module_generic_collision_1281.py new file mode 100644 index 00000000..6be4f58d --- /dev/null +++ b/tests/test_module_generic_collision_1281.py @@ -0,0 +1,717 @@ +"""#1281: E608 must not refuse two modules' PROVABLY DISTINCT generics. + +The flat-namespace collision rail exists because Pass 2.5 emits every +imported function under one WASM name, so two modules' same-named +declarations would overwrite each other. A GENERIC emits nothing under its +bare name — only clones — and since #1274 those clones live in a namespace +chosen per OWNER: a generic that owns the importer's bare name mangles to +``gen$Bool``, and one that does not (private, outside the filter, shadowed, +or reached only transitively) mangles to ``mod$$gen$Bool``. Two +generics in different owner namespaces cannot overwrite each other, and the +rail refused them anyway. + +What the rail must keep refusing is the two cases where the pair is NOT +distinct: + +* **both own the bare name** — two directly-imported, in-filter, public + generics really do mangle to one ``gen$Bool``; +* **some namespace can name both** — a module importing two dependencies + that each export ``gen`` resolves its own bare ``gen`` to one of them, and + neither the language nor the implementation has said which. Spec §8.5 + covers local-shadows-import and prescribes selective import for a + two-import clash; it defines no order between them. The checker defines + none either: its pick is a set-iteration artefact, and the identical + program accepts on one run and is rejected on the next — measured below. + Compiling that shape would pick a body by coin-flip, so it keeps its loud + refusal. The undecided rule is tracked as issue 1304. + +Every expected value is the DECLARING module's own answer, taken from the +standalone oracle — each library compiled alone with its own driver — never +from what the diamond happens to emit. Verify and run are asserted together +in each cell: a clean verify beside the wrong body is exactly the failure the +collision rail was standing in for. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import ClassVar + +import pytest + +from tests.module_fixture_helpers import build_multi_module, module_value + +BASE_ANSWER = 111 +MID1_ANSWER = 555 +DEEPB_ANSWER = 222 + + +_BASE = f"""\ +module base; + +public forall fn gen(@T -> @Int) + requires(true) + ensures(@Int.result == {BASE_ANSWER}) + effects(pure) +{{ {BASE_ANSWER} }} +""" + +# `mid1` declares its OWN `gen`, so its bare call is its own (spec §8.5.2) +# even though it also imports `base`. Private, so the importer can never +# name it: qualified-only, `mod$mid1$gen$Bool`. +_MID1 = f"""\ +module mid1; + +import base; + +private forall fn gen(@T -> @Int) + requires(true) + ensures(@Int.result == {MID1_ANSWER}) + effects(pure) +{{ {MID1_ANSWER} }} + +public fn door1(@Bool -> @Int) + requires(true) + ensures(@Int.result == {MID1_ANSWER}) + effects(pure) +{{ gen(@Bool.0) }} +""" + +# `mid2` declares no generic; its bare `gen` is `base`'s, reached through a +# wildcard import. `base` is TRANSITIVE from the entry program, so its +# generic owns no bare name there either: `mod$base$gen$Bool`. +_MID2 = f"""\ +module mid2; + +import base; + +public fn door2(@Bool -> @Int) + requires(true) + ensures(@Int.result == {BASE_ANSWER}) + effects(pure) +{{ gen(@Bool.0) }} +""" + +_DIAMOND_MAIN = f"""\ +import mid1(door1); +import mid2(door2); + +public fn main(@Unit -> @Int) + requires(true) + ensures(@Int.result == {MID1_ANSWER + BASE_ANSWER}) + effects(pure) +{{ door1(true) + door2(true) }} +""" + + +def _standalone(module_src: str, door: str, answer: int) -> str: + """A library as the ENTRY program, with its own driver. + + Only the ``module`` header goes; the library's own imports stay, because + the oracle has to be the library running against the dependencies its + source names. What is removed is the diamond — the sibling module whose + same-named generic the rail was refusing. + """ + body = module_src.split("\n", 1)[1].lstrip("\n") + return body + f""" +public fn main(@Unit -> @Int) + requires(true) + ensures(@Int.result == {answer}) + effects(pure) +{{ {door}(true) }} +""" + + +def _errors(diags: list[str], code: str) -> list[str]: + return [d for d in diags if code in d or "defined in both" in d] + + +def _answer( + tmp_path: Path, files: dict[str, str], fn: str = "main", +) -> object: + """Verify + compile + run, asserting the three agree.""" + verify_errors, result, cg_errors = build_multi_module(tmp_path, files) + assert not cg_errors, f"codegen errors: {cg_errors}" + assert not verify_errors, f"verify errors: {verify_errors}" + kind, payload = module_value(result, fn) + assert kind == "ok", f"module did not load/run: {payload}" + return payload + + +class TestStandaloneOracles: + """What each library's source commits to, with no diamond in the picture.""" + + def test_base_answers_its_own(self, tmp_path: Path) -> None: + assert _answer( + tmp_path, + {"main.vera": _standalone(_BASE, "gen", BASE_ANSWER)}, + ) == BASE_ANSWER + + def test_mid1_door_answers_its_own_generic(self, tmp_path: Path) -> None: + """``mid1`` imports ``base`` AND declares its own ``gen``; §8.5.2 says + its bare call is its own, and that is the answer the diamond must + preserve.""" + assert _answer( + tmp_path, + {"base.vera": _BASE, + "main.vera": _standalone(_MID1, "door1", MID1_ANSWER)}, + ) == MID1_ANSWER + + def test_mid2_door_answers_its_dependencys_generic( + self, tmp_path: Path, + ) -> None: + files = { + "base.vera": _BASE, + "main.vera": _standalone(_MID2, "door2", BASE_ANSWER), + } + assert _answer(tmp_path, files) == BASE_ANSWER + + +class TestDiamond: + """The issue's shape: ``base`` public, ``mid1`` private, both named + ``gen``, reached through two doors.""" + + _FILES: ClassVar[dict[str, str]] = { + "base.vera": _BASE, "mid1.vera": _MID1, + "mid2.vera": _MID2, "main.vera": _DIAMOND_MAIN, + } + + def test_compiles_and_each_door_runs_its_own_generic( + self, tmp_path: Path, + ) -> None: + assert _answer(tmp_path, dict(self._FILES)) == ( + MID1_ANSWER + BASE_ANSWER + ) + + def test_no_collision_diagnostic(self, tmp_path: Path) -> None: + _, result, _ = build_multi_module(tmp_path, dict(self._FILES)) + collisions = _errors( + [d.description for d in result.diagnostics], "E608", + ) + assert not collisions, f"E608 still refuses the pair: {collisions}" + + def test_the_two_clones_are_distinct_symbols( + self, tmp_path: Path, + ) -> None: + """The classification's claim, read off the emitted module: nothing + is emitted under a bare ``gen``, and each owner has its own clone.""" + _, result, _ = build_multi_module(tmp_path, dict(self._FILES)) + assert "(func $gen$" not in result.wat, ( + "a generic was emitted in the ENTRY's bare clone namespace, " + "where neither owner belongs" + ) + assert "(func $mod$mid1$gen$Bool" in result.wat + assert "(func $mod$base$gen$Bool" in result.wat + + +class TestTwoTransitiveImporters: + """Each module bare-calls its OWN dependency's public generic. + + Refused at COMPILE while ``vera verify`` returned rc=0 — a loud + verify-vs-compile disagreement (issue comment). After the relaxation the + two phases agree, in the direction the source means. + """ + + _DEEPA = _BASE.replace("module base;", "module deepa;") + _DEEPB = f"""\ +module deepb; + +public forall fn gen(@T -> @Int) + requires(true) + ensures(@Int.result == {DEEPB_ANSWER}) + effects(pure) +{{ {DEEPB_ANSWER} }} +""" + _MIDA = _MID2.replace("module mid2;", "module mida;").replace( + "import base;", "import deepa;", + ).replace("door2", "doora") + _MIDB = ( + _MID2.replace("module mid2;", "module midb;") + .replace("import base;", "import deepb;") + .replace("door2", "doorb") + .replace(f"== {BASE_ANSWER}", f"== {DEEPB_ANSWER}") + ) + _MAIN = f"""\ +import mida(doora); +import midb(doorb); + +public fn main(@Unit -> @Int) + requires(true) + ensures(@Int.result == {BASE_ANSWER + DEEPB_ANSWER}) + effects(pure) +{{ doora(true) + doorb(true) }} +""" + + def test_each_importer_reaches_its_own_dependency( + self, tmp_path: Path, + ) -> None: + assert _answer(tmp_path, { + "deepa.vera": self._DEEPA, "deepb.vera": self._DEEPB, + "mida.vera": self._MIDA, "midb.vera": self._MIDB, + "main.vera": self._MAIN, + }) == BASE_ANSWER + DEEPB_ANSWER + + +class TestQualifiedOnlyGenericsAreKeyedPerOwner: + """The other half the issue required: relaxing the rail without fixing + the REGISTRATION would swap a loud refusal for a silent pick-a-winner. + + A qualified-only generic emits nothing under its bare name — its clones + are ``mod$$name$…`` — but it used to inject a bare entry into the + shared registries anyway, first-module-wins, and two families of + consumer read those per NAME: ``MonoContext.fn_names`` (the #1207 shadow + guard) and the return-type registries the call-rewrite and discovery + type a bare call from. Withholding the bare key makes any surviving + entry the OWNER's by construction rather than by module order. + + **What these cells prove, honestly.** The behavioural cell below is + green today for #1299's reason, not this one: once discovery and the + clone-name override read the call site's LEXICAL scope, an invisible + declaration stops being consulted whether or not its bare key exists, and + reverting BOTH withholdings leaves every suite and all 224 conformance + programs green. So the two withholdings are now defence in depth over + four consumers that happen not to look — a `_declared_return_clone_name` + and a `_get_arg_type_info` that both bail for generics, a set-membership + test, and a WAT-type registry — and they are pinned STRUCTURALLY, on the + registration tables they act on, because that is the contract they + actually have. Two separate cells, because they are two tables: a + mutation to one must not be masked by the other. + """ + + _LIB = """\ +module lib; + +private forall fn get(@T -> @Bool) + requires(true) + ensures(true) + effects(pure) +{ true } + +public fn touch(@Int -> @Bool) + requires(true) + ensures(true) + effects(pure) +{ get(@Int.0) } +""" + + @staticmethod + def _main(helper: str) -> str: + return f"""\ +import lib(touch); + +private forall fn idg(@T -> @T) + requires(true) + ensures(true) + effects(pure) +{{ @T.0 }} + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{{ + handle[State](@Int = 42007) {{ + get(@Unit) -> {{ resume(@Int.0) }}, + put(@Int) -> {{ resume(()) }} + }} in {{ + idg({helper}(())) + }} +}} +""" + + def test_the_cells_type_drives_the_instantiation( + self, tmp_path: Path, + ) -> None: + """``idg(get(()))`` instantiates at the CELL's type. + + The checker's answer is nailed by a type oracle: the invisible + generic returns ``@Bool`` while ``main`` returns ``@Int`` from the + call and checks green, which is only possible if the call was typed + from the ``State`` cell. Pre-fix the two sides named different + clones and ``main`` was dropped [E602]. + + Kept here because it is the shape this class's fixture builds, but + the thing that holds it green is #1299's scope narrowing — the + generic sibling of the routes in + ``tests/test_lexical_fn_scope_1299.py::TestDiscoveryLeg``. + """ + assert _answer( + tmp_path, {"lib.vera": self._LIB, "main.vera": self._main("get")}, + ) == 42007 + + @staticmethod + def _diamond_registries(tmp_path: Path) -> tuple[set[str], set[str]]: + """``(_fn_sigs keys, _fn_ret_type_exprs keys)`` after the diamond.""" + from vera.checker import typecheck_with_artifacts + from vera.codegen.core import CodeGenerator + from vera.parser import parse_to_ast + from vera.resolver import ModuleResolver + + tmp_path.mkdir(parents=True, exist_ok=True) + files = { + "base.vera": _BASE, "mid1.vera": _MID1, + "mid2.vera": _MID2, "main.vera": _DIAMOND_MAIN, + } + for name, src in files.items(): + (tmp_path / name).write_text(src, encoding="utf-8") + main_path = tmp_path / "main.vera" + source = files["main.vera"] + program = parse_to_ast(source) + resolved = ModuleResolver(_root=tmp_path).resolve_imports( + program, main_path, + ) + typecheck_with_artifacts( + program, source, file=str(main_path), resolved_modules=resolved, + ) + gen = CodeGenerator( + source=source, file=str(main_path), resolved_modules=resolved, + ) + gen.compile_program(program) + return set(gen._fn_sigs), set(gen._fn_ret_type_exprs) + + def test_no_bare_signature_entry_for_a_qualified_only_generic( + self, tmp_path: Path, + ) -> None: + """``_fn_sigs`` — the registry ``MonoContext.fn_names`` and both + ``fn_ret_types`` maps are derived from — carries no bare ``gen``. + + Both modules' generics are qualified-only here, so a bare key could + only ever be one of them, chosen by whichever module registered + first. Asserted beside the guarantee that the CLONES are present, so + a fix that simply stopped registering the generics would fail. + """ + sigs, _ = self._diamond_registries(tmp_path) + assert "gen" not in sigs, ( + "a qualified-only generic injected a bare signature key; a " + "per-name consumer would take whichever module got there first" + ) + assert {"mod$mid1$gen$Bool", "mod$base$gen$Bool"} <= sigs, ( + f"the per-owner clones must still be registered, got " + f"{sorted(n for n in sigs if 'gen' in n)}" + ) + + def test_no_bare_return_type_entry_for_a_qualified_only_generic( + self, tmp_path: Path, + ) -> None: + """The same for ``_fn_ret_type_exprs``, the table the call-rewrite's + clone-naming override and discovery's argument-type recovery read. + + A separate cell from the signature one on purpose: the two + withholdings are two lines over two tables, and a single cell would + let a mutation to either hide behind the other. + """ + _, ret_exprs = self._diamond_registries(tmp_path) + assert "gen" not in ret_exprs, ( + "a qualified-only generic injected a bare return-type key" + ) + assert any("$gen$" in n for n in ret_exprs), ( + f"the per-owner clones must still carry return types, got " + f"{sorted(n for n in ret_exprs if 'gen' in n)}" + ) + + def test_rename_control_answers_the_same(self, tmp_path: Path) -> None: + """The identical importer, with only the MODULE's declaration renamed. + + Nothing about ``main`` changes, so a different answer here would mean + the fixture had stopped isolating the name collision and every cell + above was measuring something else. + """ + assert _answer( + tmp_path, + {"lib.vera": self._LIB.replace("get", "gettt"), + "main.vera": self._main("get")}, + ) == 42007 + + def test_a_bare_name_owner_still_registers_its_entry( + self, tmp_path: Path, + ) -> None: + """The withholding is per-OWNER, not a blanket exclusion of generics: + a public, in-filter, directly-imported generic owns the bare name and + keeps its registration — that is what lets the importer instantiate + it from its own call site (#774).""" + lib = """\ +module lib; + +public forall fn shared(@T -> @Int) + requires(true) + ensures(@Int.result == 111) + effects(pure) +{ 111 } +""" + main = """\ +import lib(shared); + +public fn main(@Unit -> @Int) + requires(true) + ensures(@Int.result == 111) + effects(pure) +{ shared(true) } +""" + assert _answer( + tmp_path, {"lib.vera": lib, "main.vera": main}, + ) == 111 + + +class TestTheRailStillRefusesRealCollisions: + """The relaxation is narrow. Both halves of what E608 protects stay.""" + + _LIB_A = _BASE.replace("module base;", "module liba;") + _LIB_B = f"""\ +module libb; + +public forall fn gen(@T -> @Int) + requires(true) + ensures(@Int.result == {DEEPB_ANSWER}) + effects(pure) +{{ {DEEPB_ANSWER} }} +""" + + def test_two_bare_name_owners_still_collide( + self, tmp_path: Path, + ) -> None: + """Both public, both in filter, both directly imported: both own the + entry's bare name, so both clones really do mangle to ``gen$Bool``.""" + main = """\ +import liba(gen); +import libb(gen); + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ gen(true) } +""" + _, result, cg_errors = build_multi_module( + tmp_path, + {"liba.vera": self._LIB_A, "libb.vera": self._LIB_B, + "main.vera": main}, + ) + assert _errors(cg_errors, "E608"), ( + f"the rail let two bare-name owners through: {cg_errors}" + ) + + def test_a_namespace_seeing_both_still_collides( + self, tmp_path: Path, + ) -> None: + """One module importing two dependencies that each export ``gen``. + + Its own bare ``gen`` names one of them and nothing has said which. + Both generics are qualified-only from the entry's point of view, so + the ownership classification alone would relax this; the ambiguity + gate is what keeps it loud. + + The CHECKER's pick is not an order — it is a set-iteration artefact. + Over eight runs of one unchanged program the type oracle accepted + four times and reported ``body has type Bool`` four times; under a + fixed ``PYTHONHASHSEED`` it is stable, and it varies with the seed + rather than with which import is written first (the ``a, b`` and + ``b, a`` spellings give identical seed-by-seed answers). Codegen's + reroute map IS positional, last-wins. So relaxing here would compile + a body chosen by coin-flip, and would do it from a program the + checker itself rejects on half its runs. Tracked as issue 1304; + settling it there is what would let this gate go. + """ + midc = """\ +module midc; + +import liba; +import libb; + +public fn doorc(@Bool -> @Int) + requires(true) + ensures(true) + effects(pure) +{ gen(@Bool.0) } +""" + main = """\ +import midc(doorc); + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ doorc(true) } +""" + _, result, cg_errors = build_multi_module( + tmp_path, + {"liba.vera": self._LIB_A, "libb.vera": self._LIB_B, + "midc.vera": midc, "main.vera": main}, + ) + assert _errors(cg_errors, "E608"), ( + f"an ambiguous bare name was let through: {cg_errors}" + ) + + def test_a_generic_beside_a_non_generic_still_collides( + self, tmp_path: Path, + ) -> None: + """The relaxation is for GENERICS, and stays there. + + A qualified-only generic in one module beside a same-named + NON-generic in another occupies two different flat identities too — + ``mod$liba$gen$Bool`` and ``$gen`` — so the two conditions below it + would let the pair through. It keeps its refusal because nobody has + measured that shape, and the narrow scope is the point: a relaxation + is only as good as the classification behind it, and the + classification (``module_qualified_generic_names``) speaks about + generics. + """ + liba = f"""\ +module liba; + +private forall fn gen(@T -> @Int) + requires(true) + ensures(true) + effects(pure) +{{ {BASE_ANSWER} }} + +public fn door1(@Bool -> @Int) + requires(true) + ensures(true) + effects(pure) +{{ gen(@Bool.0) }} +""" + libb = f"""\ +module libb; + +private fn gen(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{{ {DEEPB_ANSWER} }} + +public fn door2(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{{ gen(()) }} +""" + main = """\ +import liba(door1); +import libb(door2); + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ door1(true) + door2(()) } +""" + _, result, cg_errors = build_multi_module( + tmp_path, + {"liba.vera": liba, "libb.vera": libb, "main.vera": main}, + ) + assert _errors(cg_errors, "E608"), ( + f"a generic/non-generic pair was let through: {cg_errors}" + ) + + def test_two_bare_name_owners_collide_whatever_the_ambiguity_says( + self, + ) -> None: + """The owner condition, asked of the predicate directly. + + End to end it is belt and braces: two generics can only BOTH own the + entry's bare name when the entry imports both, publicly and in + filter, and declares neither — which is exactly what makes the name + ambiguous there, so the gate above catches the shape first. The two + conditions coincide through a chain of reasoning about two + separately-derived tables, and a drift between them would silently + relax a real clone collision, so the condition is asserted on its own + rather than left resting on that coincidence. + """ + from vera.codegen.core import CodeGenerator + + gen = CodeGenerator(source="", file="") + gen._ambiguous_imported_fn_names = frozenset() + generics = {("a",): frozenset({"gen"}), ("b",): frozenset({"gen"})} + # Neither is qualified-only: both own the entry's bare name, so both + # sets of clones mangle to `gen$…`. + assert not gen._generics_cannot_collide( + "gen", ("a",), ("b",), generics, {("a",): set(), ("b",): set()}, + ) + # One qualified-only: distinct namespaces, so the pair is fine. + assert gen._generics_cannot_collide( + "gen", ("a",), ("b",), generics, + {("a",): {"gen"}, ("b",): set()}, + ) + + def test_a_local_declaration_disambiguates_two_dependencies( + self, tmp_path: Path, + ) -> None: + """A namespace importing two ``gen``s but declaring its OWN is not + ambiguous: §8.5.2 gives every bare call in it to the local + declaration, so the two imports are never resolved against. + + Three same-named generics, all qualified-only, all in distinct clone + namespaces — the diamond one module wider. + """ + midd = f"""\ +module midd; + +import liba; +import libb; + +private forall fn gen(@T -> @Int) + requires(true) + ensures(@Int.result == {MID1_ANSWER}) + effects(pure) +{{ {MID1_ANSWER} }} + +public fn doord(@Bool -> @Int) + requires(true) + ensures(@Int.result == {MID1_ANSWER}) + effects(pure) +{{ gen(@Bool.0) }} +""" + main = f"""\ +import midd(doord); + +public fn main(@Unit -> @Int) + requires(true) + ensures(@Int.result == {MID1_ANSWER}) + effects(pure) +{{ doord(true) }} +""" + assert _answer(tmp_path, { + "liba.vera": self._LIB_A, "libb.vera": self._LIB_B, + "midd.vera": midd, "main.vera": main, + }) == MID1_ANSWER + + @pytest.mark.parametrize("vis", ["public", "private"]) + def test_two_non_generics_still_collide( + self, tmp_path: Path, vis: str, + ) -> None: + """Non-generics are untouched: each really is emitted under the bare + ``$name`` in Pass 2.5, whatever its visibility.""" + lib = f"""\ +module lib{{n}}; + +{vis} fn plain(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{{{{ 1 }}}} + +public fn door{{n}}(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{{{{ plain(()) }}}} +""" + main = """\ +import lib1(door1); +import lib2(door2); + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ door1(()) + door2(()) } +""" + _, result, cg_errors = build_multi_module( + tmp_path, + {"lib1.vera": lib.replace("{n}", "1"), + "lib2.vera": lib.replace("{n}", "2"), + "main.vera": main}, + ) + assert _errors(cg_errors, "E608"), ( + f"two same-named non-generics were let through: {cg_errors}" + ) diff --git a/tests/test_monomorphize_differential.py b/tests/test_monomorphize_differential.py index 0485fdc1..d5aaef93 100644 --- a/tests/test_monomorphize_differential.py +++ b/tests/test_monomorphize_differential.py @@ -35,7 +35,9 @@ import pytest from vera.codegen.core import CodeGenerator +from vera.monomorphize import Monomorphizer from vera.parser import parse_file +from vera.resolver import ModuleResolver from vera.transform import transform from vera.verifier import ContractVerifier @@ -409,6 +411,90 @@ def _verifier_discovered( +def _discovery_scopes( + program: object, source: str, path: str, modules: list[object] | None = None, +) -> tuple[dict[str, set[frozenset[str]]], dict[str, set[frozenset[str]]]]: + """``(codegen, verifier)`` per-declaration discovery scopes (#1299). + + Records, for every declaration each side's instantiation-discovery walk + enters, the set of bare names that walk resolves against. That set is the + input to the ownership predicate discovery asks to decide whether a bare + ``get`` is an effect operation — so if the two sides compute it + differently they can name different clones from the same source, which is + the shape a coverage-only differential cannot see (both sides can be + wrong together and still be equal). + """ + # name -> the SET of distinct NAMESPACE scopes that declaration was walked + # under. Two refinements, each for a difference that is real but not the + # one under test: + # + # * a SET, not a single value — two modules' same-named generics reach + # the walk under one pre-rename clone name (`gen$Bool`), so keeping + # only the first would compare `mid1`'s scope on one side against + # `base`'s on the other and call an ordering artefact a divergence; + # * the walked declaration's own `where` helpers are subtracted, because + # codegen walks a clone both before and after `_hoist_clone_where_fns` + # strips them while the verifier walks it once. Those two scopes + # differ by exactly the helper names, and both are right for what they + # walk. The helper accumulation is pinned on its own by + # ``TestDiscoveryWalkContract``; what is compared here is the part that + # comes from the namespace tables and the namespace SELECTION. + captured: dict[str, set[frozenset[str]]] = {} + original = Monomorphizer._collect_calls_in_node_scoped + + def recording(inner, fn, *a): # type: ignore[no-untyped-def] + if inner._scope_fn_names is not None: + own_helpers = {wfn.name for wfn in (fn.where_fns or ())} + captured.setdefault(fn.name, set()).add( + frozenset(inner._scope_fn_names) - own_helpers, + ) + return original(inner, fn, *a) + + Monomorphizer._collect_calls_in_node_scoped = recording # type: ignore[method-assign] + try: + gen = CodeGenerator( + source=source, file=path, resolved_modules=modules or [], + ) + gen.compile_program(program) # type: ignore[arg-type] + codegen_scopes = dict(captured) + + captured.clear() + verifier = ContractVerifier( + source=source, file=path, resolved_modules=modules or [], + ) + verifier.register_program(program) # type: ignore[arg-type] + verifier_scopes = dict(captured) + finally: + Monomorphizer._collect_calls_in_node_scoped = original # type: ignore[method-assign] + return codegen_scopes, verifier_scopes + + +def _assert_scopes_agree( + label: str, + codegen_scopes: dict[str, set[frozenset[str]]], + verifier_scopes: dict[str, set[frozenset[str]]], +) -> None: + """Both sides resolved every shared declaration against the same names.""" + shared = sorted(set(codegen_scopes) & set(verifier_scopes)) + assert shared, ( + f"[{label}] neither side entered a discovery scope for any shared " + f"declaration — the comparison would pass vacuously " + f"(codegen={sorted(codegen_scopes)}, verifier={sorted(verifier_scopes)})" + ) + for name in shared: + cg, ver = codegen_scopes[name], verifier_scopes[name] + if cg == ver: + continue + cg_only = sorted(sorted(s) for s in cg - ver) + ver_only = sorted(sorted(s) for s in ver - cg) + raise AssertionError( + f"[{label}] discovery scopes disagree at the first divergent " + f"declaration {name!r}:\n" + f" codegen-only scopes = {cg_only}\n" + f" verifier-only scopes = {ver_only}" + ) + + def _cross_module_sets( main_src: str, modules: list[object], ) -> tuple[set[tuple[str, tuple[str, ...]]], set[tuple[str, tuple[str, ...]]]]: @@ -1787,3 +1873,319 @@ def test_mono_emission_order_is_deterministic(tmp_path: Path) -> None: f"`vera compile --wat` not byte-stable across PYTHONHASHSEED: " f"{len(outputs)} distinct outputs" ) + + +def test_invisible_import_does_not_name_a_clone_on_either_side() -> None: + """`#1299`: an INVISIBLE module declaration must name no clone, on either + side. + + Discovery's ``MonoContext.fn_names`` is the consumer's flat registry — it + has to hold every symbol the guard rail resolves against, including an + imported module's ``private fn get``. Read flat, it claimed a bare + ``get(())`` the checker had resolved to the ``State`` operation, and + the argument's type was taken from that invisible declaration: both sides + discovered ``idg`` where the cell says ``idg``. + + Both sides moved together, which is the point of asserting it HERE rather + than only on the runtime value. Codegen and the verifier were WRONG + SYMMETRICALLY before the fix, so an equality-only differential passed; had + only one side been narrowed, the equality below would now fail and the + other side would be verifying a clone nobody emits — a false Tier-1 with + no runtime symptom at all. The membership assertion is what distinguishes + "both right" from "both wrong". + """ + mod = _resolved_module(("lib_inv",), ( + "private fn get(@Unit -> @Bool)\n" + " requires(true) ensures(true) effects(pure)\n" + "{ true }\n" + "public fn touch(@Unit -> @Bool)\n" + " requires(true) ensures(true) effects(pure)\n" + "{ get(()) }\n" + )) + main_src = ( + "import lib_inv(touch);\n" + "private forall fn idg(@T -> @T)\n" + " requires(true) ensures(true) effects(pure)\n" + "{ @T.0 }\n" + "public fn main(@Unit -> @Int)\n" + " requires(true) ensures(true) effects(pure)\n" + "{\n" + " handle[State](@Int = 42007) {\n" + " get(@Unit) -> { resume(@Int.0) },\n" + " put(@Int) -> { resume(()) }\n" + " } in {\n" + " idg(get(()))\n" + " }\n" + "}\n" + ) + codegen_set, verifier_set = _cross_module_sets(main_src, [mod]) + + assert ("idg", ("Int",)) in codegen_set, ( + f"the cell's type must name the clone — the checker resolved the " + f"operation, so `idg` is the instantiation; got " + f"{sorted(codegen_set)}" + ) + assert ("idg", ("Bool",)) not in codegen_set, ( + f"an invisible module declaration named the clone: {sorted(codegen_set)}" + ) + assert verifier_set == codegen_set, ( + f"verifier ({sorted(verifier_set)}) must discover exactly codegen's " + f"emitted set ({sorted(codegen_set)}) — narrowing one side's discovery " + f"scope and not the other leaves a clone verified that nobody emits" + ) + + +# --- #1299: the two sides' discovery SCOPES, not just their results --------- + +# Programs chosen so the comparison covers every input to the scope: the +# PRELUDE (injected into the entry namespace at a different pass on each +# side), CROSS-MODULE imports, the same-named-generic DIAMOND, and +# WHERE-nesting under a generic parent. +_SCOPE_REPO_CORPUS = [ + "tests/conformance/ch04_pipe_module_call.vera", + "tests/conformance/ch08_cross_module_generic.vera", + "tests/conformance/ch08_module_generic_diamond.vera", + "tests/conformance/ch07_invisible_import_op_name.vera", + "tests/conformance/ch09_generic_where_helper.vera", +] + + +@pytest.mark.parametrize("rel", _SCOPE_REPO_CORPUS) +def test_discovery_scopes_agree_between_the_two_sides(rel: str) -> None: + """Codegen and the verifier narrow discovery to the SAME names (#1299). + + The coverage differentials above compare what each side DISCOVERS. This + compares what each side discovers it FROM, and the distinction is the + point: two sides reading different scopes can agree on every clone for + every program in the corpus and still disagree on the first program where + a name is both a declaration and an operation — because being wrong + together is invisible to an equality of results. + + Three inputs had to be made to agree for this to hold, and each was a + real divergence measured on 22 of the 22 module-using conformance + programs: the PRELUDE (the verifier builds its tables after + ``inject_prelude`` and codegen before, so the entry namespace differed by + the five combinators), and the per-declaration namespace of a CLONE (the + two sides key their origin registries differently, so the same clone was + walked in the entry's namespace on one side and its module's on the + other). + """ + path = _REPO_ROOT / rel + source = path.read_text(encoding="utf-8") + program = transform(parse_file(str(path))) + resolved = ModuleResolver(_root=path.parent).resolve_imports( + program, path, + ) + cg, ver = _discovery_scopes( + program, source, str(path), list(resolved), + ) + _assert_scopes_agree(rel, cg, ver) + + +# Module shapes whose clones are reached by the SHADOWED-generic worklist and +# the nested-helper chase — walks the repo corpus above never enters, and each +# with its own namespace-selection site on both sides. +_SCOPE_MODULE_CASES: dict[str, tuple[str, str, tuple[str, ...]]] = { + # A shadowed module generic whose body reaches a normal (unshadowed) one: + # codegen's `_chase_normal_transitive` / the verifier's + # `_chase_normal_from_clone`, rooted at the shadowed clone. + "shadowed_reaching_normal": ( + "sh", + "public forall fn plain(@T -> @Int)\n" + " requires(true) ensures(true) effects(pure)\n" + "{ 1 }\n" + "public forall fn gen(@T -> @Int)\n" + " requires(true) ensures(true) effects(pure)\n" + "{ plain(@T.0) }\n", + # The local shadow is NON-generic on purpose. It still makes the + # module's `gen` qualified-only — `importer_occupied_bare_names` + # counts every top-level name, generic or not — which is what routes + # the clone through the shadowed worklist and its normal-closure + # chase. A generic shadow would ALSO mint a clone under the same + # pre-rename name `gen$Int`, and the two sides' scope sets would then + # differ for a reason that has nothing to do with which namespace + # either picked. + ( + "import sh;\n\n" + "private fn gen(@Int -> @Int)\n" + " requires(true) ensures(true) effects(pure)\n" + "{ 9 }\n\n" + "public fn main(@Unit -> @Int)\n" + " requires(true) ensures(true) effects(pure)\n" + "{ sh::gen(5) + gen(1) }\n" + ), + ), + # A `forall` where-helper under a PRIVATE module generic: the nested-helper + # clone walk, whose namespace comes from the recorded lexical CHAIN. + "nested_helper_under_private_generic": ( + "nh", + # `ginner` calls a module-PRIVATE function and a top-level generic: + # the first makes the helper clone's scope observable (`onlyhere` is + # in the module's namespace and in no other), the second drives + # #1223's `pending_top` so the helper-clone walk actually runs. + "private fn onlyhere(@Unit -> @Int)\n" + " requires(true) ensures(true) effects(pure)\n" + "{ 4 }\n" + "public forall fn topgen(@T -> @Int)\n" + " requires(true) ensures(true) effects(pure)\n" + "{ 5 }\n" + "private forall fn priv_outer(@T -> @Int)\n" + " requires(true) ensures(true) effects(pure)\n" + "{ ginner(@T.0) }\n" + "where {\n" + " forall fn ginner(@U -> @Int)\n" + " requires(true) ensures(true) effects(pure)\n" + " { onlyhere(()) + topgen(true) }\n" + "}\n" + "public forall fn pub_entry(@T -> @Int)\n" + " requires(true) ensures(true) effects(pure)\n" + "{ priv_outer(@T.0) }\n", + ( + "import nh(pub_entry);\n" + "public fn main(@Unit -> @Int)\n" + " requires(true) ensures(true) effects(pure)\n" + "{ pub_entry(7) }\n" + ), + ), +} + + +@pytest.mark.parametrize("label", sorted(_SCOPE_MODULE_CASES)) +def test_discovery_scopes_agree_on_module_clone_walks(label: str) -> None: + """The same equality, over the clone walks the repo corpus never reaches. + + Both sides chase clones from THREE further places — the shadowed-generic + worklist's normal-closure chase, and the nested generic-under-generic + helper clone — and each picks the namespace by its own route: codegen + from ``_mono_clone_origins`` keyed by clone name, the verifier from + ``_origin_module_for_generic`` keyed by the base chain. A shadowed clone + reaches those walks under its PRE-rename name, which is in neither + registry, so both sides take the path from the caller instead. + """ + mod_name, mod_src, main_src = _SCOPE_MODULE_CASES[label] + mod = _resolved_module((mod_name,), mod_src) + with tempfile.NamedTemporaryFile( + mode="w", suffix=".vera", delete=False, encoding="utf-8", + ) as f: + f.write(main_src) + mp = f.name + try: + program = transform(parse_file(mp)) + cg, ver = _discovery_scopes(program, main_src, mp, [mod]) + finally: + os.unlink(mp) + _assert_scopes_agree(label, cg, ver) + + +def test_nested_helper_clone_walks_in_its_chains_module() -> None: + """The verifier's nested-helper clone walk enters the CHAIN's namespace. + + Asserted directly rather than through the equality above, because that + comparison is keyed by declaration NAME and the two sides name this one + clone differently by design: codegen hoists it per instantiation + (``mod$nh$priv_outer$Int$where$ginner$Int``) while the verifier keeps the + mangled bare name (``ginner$Int``). Not a scope divergence — a naming + scheme difference — so the equality cell cannot see this site, and it + gets its own discriminator: ``onlyhere`` is module-PRIVATE, so it is in + the walk's scope only if the walk entered ``nh``'s namespace. + """ + mod_name, mod_src, main_src = _SCOPE_MODULE_CASES[ + "nested_helper_under_private_generic" + ] + mod = _resolved_module((mod_name,), mod_src) + with tempfile.NamedTemporaryFile( + mode="w", suffix=".vera", delete=False, encoding="utf-8", + ) as f: + f.write(main_src) + mp = f.name + try: + program = transform(parse_file(mp)) + _cg, ver = _discovery_scopes(program, main_src, mp, [mod]) + finally: + os.unlink(mp) + + assert "ginner$Int" in ver, ( + f"the nested helper's clone was never walked in a discovery scope — " + f"the assertion below would be vacuous; keys were {sorted(ver)}" + ) + for scope in ver["ginner$Int"]: + assert "onlyhere" in scope, ( + f"the nested helper's clone was walked in the wrong namespace: " + f"its module's private `onlyhere` is not in scope {sorted(scope)}" + ) + + +def test_codegen_helper_family_leaf_walks_in_its_parents_module() -> None: + """Codegen's twin of the assertion above, on the helper-family LEAF. + + ``collect_generic_helper_instances`` is the one walk both sides drive + directly, and codegen hoists the clone it produces under a per-clone name + (``mod$nh$priv_outer$Int$where$ginner$Int``) where the verifier keeps the + mangled bare one — so the two are never a shared key and the equality + cell cannot compare them. Each side therefore gets a direct + discriminator: ``onlyhere`` is module-private, in scope only if the leaf + entered ``nh``'s namespace rather than the entry program's. + """ + mod_name, mod_src, main_src = _SCOPE_MODULE_CASES[ + "nested_helper_under_private_generic" + ] + mod = _resolved_module((mod_name,), mod_src) + with tempfile.NamedTemporaryFile( + mode="w", suffix=".vera", delete=False, encoding="utf-8", + ) as f: + f.write(main_src) + mp = f.name + try: + program = transform(parse_file(mp)) + cg, _ver = _discovery_scopes(program, main_src, mp, [mod]) + finally: + os.unlink(mp) + + walked = [n for n in cg if n.startswith("mod$nh$") and "$where$" in n] + assert walked, ( + f"codegen walked no hoisted helper clone — the assertion would be " + f"vacuous; keys were {sorted(cg)}" + ) + for name in walked: + for scope in cg[name]: + assert "onlyhere" in scope, ( + f"{name} was walked in the wrong namespace: its module's " + f"private `onlyhere` is not in scope {sorted(scope)}" + ) + + +def test_discovery_scope_includes_the_prelude() -> None: + """A prelude combinator is a declaration every namespace can name. + + Its dispatch-side sibling (``test_prelude_names_stay_in_every_scope``) + makes the same assertion about ``_scoped_fns``; without this one the + discovery half could drop the prelude and nothing would notice, because + no prelude name is spelled like an effect operation *yet*. + """ + src = ( + "private forall fn idg(@T -> @T)\n" + " requires(true) ensures(true) effects(pure)\n" + "{ @T.0 }\n\n" + "public fn main(@Unit -> @Int)\n" + " requires(true) ensures(true) effects(pure)\n" + "{ idg(7) }\n" + ) + with tempfile.NamedTemporaryFile( + mode="w", suffix=".vera", delete=False, encoding="utf-8", + ) as f: + f.write(src) + mp = f.name + try: + program = transform(parse_file(mp)) + cg, ver = _discovery_scopes(program, src, mp) + finally: + os.unlink(mp) + + assert cg and ver, "no declaration was walked in a discovery scope" + for label, scopes in (("codegen", cg), ("verifier", ver)): + for scope in scopes["main"]: + assert "option_map" in scope, ( + f"{label} dropped the prelude from the discovery scope: " + f"{sorted(scope)}" + ) + _assert_scopes_agree("prelude", cg, ver) diff --git a/vera/checker/core.py b/vera/checker/core.py index 7e6d3b3c..7661ffec 100644 --- a/vera/checker/core.py +++ b/vera/checker/core.py @@ -939,9 +939,21 @@ def _user_fn_names(self) -> Container[str]: What :func:`~vera.slots.bare_call_denotes_user_fn` consults on this side: a name is the user's declaration here exactly when :meth:`_lookup_function_scoped` resolves it, so the ownership - predicate reads the LEXICAL scope this checker resolves against — - not a flattened copy of it that could answer differently. Codegen - passes its own flat ``_fn_sigs`` mirror to the same predicate. + predicate reads whatever this checker actually resolves against + rather than a separate copy that could answer differently. Codegen + passes ``_scoped_fns`` — its registry narrowed to the compiling + declaration's lexical scope (#1299). + + The two are not yet the same scope, and the residue is on THIS side: + ``register_fn`` recurses ``where`` helpers into the flat + ``TypeEnv``, and the lookup above falls back to it, so a bare call in + a SIBLING top-level function resolves to another function's helper — + which spec §5 makes local to its parent. Codegen refuses that + program (the helper is emitted as ``parent$where$name``, so the bare + call has no target) and, where the helper's name is an operation's, + lowers the operation the spec prescribes while the checker reports + against the helper's signature. Tracked as #1307; the fix is a + checker change with its own new rejections, not a table change here. """ return _ScopedFnNames(self._lookup_function_scoped) diff --git a/vera/codegen/closures.py b/vera/codegen/closures.py index 1b915827..dc908fe2 100644 --- a/vera/codegen/closures.py +++ b/vera/codegen/closures.py @@ -175,6 +175,12 @@ def _lift_pending_closures(self, ctx: WasmContext) -> bool: lifted_wat = self._compile_lifted_closure( closure_id, anon_fn, captures, collect_pending=inner_pending, + # #1299: a closure body is lexically INSIDE the function + # being compiled, so it resolves bare names in that + # function's scope. Carried from the parent context + # rather than rebuilt: the lift has no declaration to + # derive it from, and a rebuilt copy could drift. + scoped_fns=ctx._scoped_fns, ) except CodegenInvariantError: # #657: a closure-body invariant (codegen bug) aborts the @@ -290,6 +296,7 @@ def _compile_lifted_closure( list[tuple[ast.AnonFn, list[tuple[str, int, str]], int]] | None ) = None, + scoped_fns: set[str] | None = None, ) -> str | None: """Compile an anonymous function to a module-level WASM function. @@ -328,6 +335,11 @@ def _compile_lifted_closure( generic_fn_info=getattr(self, "_generic_fn_info", None), ctor_to_adt=ctor_to_adt, known_fns=set(self._fn_sigs.keys()), + # #1299: the enclosing function's lexical scope, threaded from + # its context by `_lift_pending_closures`. ``None`` (a caller + # that lifts outside a function compile) falls back to the flat + # registry inside `WasmContext`, which is the pre-#1299 answer. + scoped_fns=scoped_fns, ctor_adt_tp_indices=getattr(self, "_ctor_adt_tp_indices", None), adt_tp_counts=getattr(self, "_adt_tp_counts", None), adt_tp_param_names=getattr(self, "_adt_tp_param_names", None), diff --git a/vera/codegen/core.py b/vera/codegen/core.py index f21c54d0..526ec1ac 100644 --- a/vera/codegen/core.py +++ b/vera/codegen/core.py @@ -27,7 +27,11 @@ from vera.codegen.api import CompileResult from vera.codegen.memory import ConstructorLayout from vera.errors import Diagnostic, SourceLocation -from vera.monomorphize import canonicalize_type_aliases, qualify_nested_generic_decls +from vera.monomorphize import ( + NamespaceFnNames, + canonicalize_type_aliases, + qualify_nested_generic_decls, +) from vera.naming import EMPTY_ALIAS_ENV, AliasEnv from vera.prelude import PRELUDE_FILE, mentioned_fn_names from vera.slots import family_fallback_name @@ -590,6 +594,34 @@ def __init__( # transitive symbol from a *main-program* body fails loudly at compile # instead of silently resolving to the emitted-for-a-sibling body. self._transitive_only_names: set[str] = set() + # #1299: namespace path (``None`` = the main program) → the bare + # SOURCE function names a body compiled in that namespace can NAME. + # Codegen absorbs every module into one flat WASM namespace, so + # `_fn_sigs` cannot answer "whose declaration is this bare `get`?" — + # it holds a module's private helpers, the public ones an import + # filter excluded, and every transitive module's declarations, none + # of which the checker resolved against. This map is what + # `_scoped_fn_names` narrows `_fn_sigs` down to before the #1284 + # ownership predicate reads it. Computed once in + # `_collect_namespace_fn_names`, after the Pass-0 transforms. + self._namespace_fn_names: dict[ + tuple[str, ...] | None, frozenset[str] + ] = {} + # The same tables as the shared value `MonoContext` carries, so Pass + # 1.5's discovery narrows against exactly what `_scoped_fn_names` + # narrows against (#1299). + self._namespace_tables: NamespaceFnNames | None = None + # #1281: bare names some namespace could resolve to more than one + # module's declaration — a module importing two dependencies that + # each export `gen`, and declaring no `gen` of its own. Spec §8.5 + # defines no order between two imports (it prescribes naming the one + # you want), and neither does the checker: its pick is a + # set-iteration artefact that flips between runs of the identical + # program (#1304). The E608 relaxation therefore fires only for + # names OUTSIDE this set, so the shape keeps its loud refusal instead + # of compiling to whichever body that coin-flip favoured. Filled + # beside `_namespace_fn_names`, from the same walk. + self._ambiguous_imported_fn_names: frozenset[str] = frozenset() # #774: imported PUBLIC generic (`forall`) FnDecls the importer must # monomorphize itself — cross-module generic monomorphization. The # importer discovers instantiations from ITS OWN call sites and emits @@ -916,6 +948,7 @@ def _compile_fn_tracked( module_tables: ( tuple[SpanTypeTable | None, SpanTypeTable | None] | None ) = None, + where_scope: frozenset[str] = frozenset(), ) -> str | None: """`_compile_fn` plus the #1100 skip/closure bookkeeping. @@ -926,6 +959,14 @@ def _compile_fn_tracked( that explains each drop, and (b) which lifted closures belong to which parent (a parent holds only a table index, so the construction edge is invisible to a WAT-text scan). + + *where_scope* (#1299) is the ``where``-helper names lexically in + scope in *decl*'s body — the direct helpers of every enclosing + function, plus *decl*'s own. It cannot be recovered from *decl*: a + helper node carries no parent link, and the ancestors' helpers are + exactly what the checker's ``_lookup_function_scoped`` walks. The + default is right for a top-level declaration with no helpers, which + is every caller that omits it. """ diags_before = len(self.diagnostics) closures_before = len(self._closure_fns_wat) @@ -936,6 +977,7 @@ def _compile_fn_tracked( fn_wat = self._compile_fn( decl, export=export, module_renames=module_renames, imported=imported, module_tables=module_tables, + where_scope=where_scope, ) if fn_wat is None: # The LAST codegen diagnostic emitted during this compile is @@ -1488,6 +1530,16 @@ def compile_program(self, program: ast.Program) -> CompileResult: program = self._hoist_nongeneric_where_helpers(program) + # #1299 / #1281: record which function names each namespace can NAME + # — and which of those are ambiguous — before anything registers or + # compiles against the flat registry. Not folded into + # `_register_modules`: that returns early when the program imports + # nothing, and the entry program still needs its own set (a + # `forall` parent's `where` helper puts an out-of-scope bare name + # in `_fn_sigs` with no module in sight). Ordered BEFORE it because + # the E608 rail inside reads the ambiguity half. + self._collect_namespace_fn_names(program) + # Pass 0.5: register imported module declarations (C7e) self._register_modules(program) @@ -1581,6 +1633,15 @@ def compile_program(self, program: ast.Program) -> CompileResult: # #1208: prelude aliases and ADTs are now in the flat maps too. self._sync_alias_env() + # #1299: and so are the prelude's FUNCTIONS, which belong to every + # namespace. Rebuild the visibility tables now that + # `_prelude_fn_names` is populated — Pass 0.5's call could not know + # them, and the verifier builds ITS tables from a post-injection + # program, so leaving them out here made the two sides' discovery + # scopes differ by exactly the five combinators on every + # module-using program. + self._collect_namespace_fn_names(program) + # #305: Pass-1 signatures for USER fns whose params/return # reference prelude ADTs (Request/Response/Json/HtmlNode) were # computed before the prelude registered those layouts, so they @@ -1749,7 +1810,12 @@ def _dec_collect(fdecl: ast.FnDecl, emit_name: str) -> None: decl = tld.decl if isinstance(decl, ast.FnDecl): is_public = tld.visibility == "public" - fn_wat = self._compile_fn_tracked(decl, export=is_public) + fn_wat = self._compile_fn_tracked( + decl, export=is_public, + where_scope=frozenset( + w.name for w in decl.where_fns or () + ), + ) if fn_wat is not None: functions_wat.append(fn_wat) if is_public: @@ -1765,8 +1831,13 @@ def _dec_collect(fdecl: ast.FnDecl, emit_name: str) -> None: # (`unknown func` at WAT assembly). The generic path # already flattens nested helpers via # `monomorphize._hoist_where_fns_under`. - for wfn in self._flatten_where_fns(decl): - wfn_wat = self._compile_fn_tracked(wfn, export=False) + # #1299: paired with the scope each helper's own body + # resolves in — its ancestors' direct helpers plus its + # own, which is what the checker walks. + for wfn, wscope in self._where_fn_scopes(decl): + wfn_wat = self._compile_fn_tracked( + wfn, export=False, where_scope=wscope, + ) if wfn_wat is not None: # PR #1013 review: a fully-concrete (T-unused) # generic helper TEMPLATE compiles — unlike a @@ -1852,6 +1923,16 @@ def _dec_collect(fdecl: ast.FnDecl, emit_name: str) -> None: self._module_artifacts.get(origin) if origin is not None else None ), + # #1299: no `where_scope` — a clone reaching here has + # none to give. `_hoist_clone_where_fns` strips + # `where_fns` off every clone and re-queues the helpers as + # standalone mono decls under clone-qualified names + # (`holder$Bool$where$get`), rewriting the clone's own + # calls with them, so the bare helper name is gone from + # the body before this loop sees it. Pinned as an + # invariant rather than defended with a dead argument: + # test_lexical_fn_scope_1299 asserts no mono decl arrives + # carrying helpers, and goes red if that ever changes. ) if fn_wat is not None: functions_wat.append(fn_wat) @@ -1891,6 +1972,15 @@ def _dec_collect(fdecl: ast.FnDecl, emit_name: str) -> None: # #987: thread THIS module's own span-keyed tables so the # imported body's @Nat -> @Int widening guard fires. module_tables=self._module_artifacts.get(path), + # #1299: no `where_scope`. This body resolves bare names + # in ITS module's namespace, which the alias scope above + # already selects, and it brings no bare helper name of + # its own: `_register_modules` runs the #991 hoist and the + # #1014 qualification over every module AST, so an + # imported declaration arriving here carries only + # `$`-qualified helpers — admitted unconditionally. The + # door invariant in test_lexical_fn_scope_1299 holds every + # emission site to that, and goes red if one stops. ) if fn_wat is not None: functions_wat.append(fn_wat) @@ -1925,6 +2015,9 @@ def _dec_collect(fdecl: ast.FnDecl, emit_name: str) -> None: # THIS module's table still keys them correctly and its # widen guard fires. module_tables=self._module_artifacts.get(path), + # #1299: the ``mod$…`` rename moves the body into no other + # namespace, and adds no helper — same reasoning, and the + # same door invariant, as the Pass-2.5 emission above. ) if fn_wat is not None: functions_wat.append(fn_wat) @@ -2528,6 +2621,93 @@ def _rewrite_generic_subtree_shadowed( rewritten, where_fns=new_where or None, ) + def _scoped_fn_names( + self, where_scope: frozenset[str], own_name: str, + ) -> set[str]: + """The registered names a bare call in this body may DENOTE (#1299). + + ``_fn_sigs`` narrowed to the compiling declaration's lexical scope: + its namespace's own declarations, visible imports and the prelude + (:meth:`_collect_namespace_fn_names`, selected by the module scope + ``_module_alias_scope`` currently has installed), the ``where`` + helpers in scope, and the declaration itself for recursion. + This is what codegen hands + :func:`~vera.slots.bare_call_denotes_user_fn`; the flat registry + stays behind ``_known_fns`` for the guard rail, which asks a + different question ("is there a symbol here?") that IS flat. + + A strict SUBSET of ``_fn_sigs`` by construction — the comprehension + iterates the registry — so this can only ever withdraw a name the + pre-#1299 table wrongly claimed, never introduce one with no + signature behind it. ``tests/test_lexical_fn_scope_1299.py`` pins + that as a property rather than leaving it to the reading. + + Every ``$``-bearing key is admitted unconditionally. ``$`` cannot + occur in a Vera identifier (``LOWER_IDENT``), so a mangled name is + never what a bare source call spells; what admitting it DOES is keep + a mono clone (``pick$Int``), a rerouted module body (``mod$lib$f``), + and a hoisted helper (``outer$where$h``) answering "user-owned" at + the sites that see a call name the rewrite already resolved. + + This implements the SPEC's rule — §7.4 resolves a bare operation only + for a name no declaration in the call site's scope occupies, and §5 + makes a ``where`` helper local to its parent — rather than reproducing + what the checker currently computes. The two coincide everywhere + except one shape: ``register_fn`` recurses helpers into the flat + ``TypeEnv``, so the checker also resolves a bare call in a SIBLING + top-level function to another function's helper, which this set does + not (#1307). Where that shape's helper is named after an operation + the two now disagree in the checker's direction, and closing it is a + checker change with its own new rejections. + """ + lexical = set( + self._namespace_fn_names.get(self._active_module_path, ()) + ) + lexical |= where_scope + lexical.add(own_name) + return { + name for name in self._fn_sigs + if "$" in name or name in lexical + } + + @staticmethod + def _where_fn_scopes( + decl: ast.FnDecl, + ) -> list[tuple[ast.FnDecl, frozenset[str]]]: + """:meth:`_flatten_where_fns`, each helper paired with ITS scope. + + The scope of a helper is the direct ``where`` names of every + enclosing function up to and including itself — spec §5's helper + locality, which the checker's ``_lookup_function_scoped`` frame-stack + walk also implements, so a grandchild helper is NOT in its + grandparent's scope and a sibling is. ("Also", not "exactly": the + checker's env fallback additionally reaches helpers from OUTSIDE the + frame stack entirely, which is #1307 and not this walk's rule.) + + Same traversal, same skip, same order as :meth:`_flatten_where_fns`; + the two are asserted to enumerate identically rather than kept in + step by inspection, because a helper this one missed would compile + against the wrong scope silently. + """ + out: list[tuple[ast.FnDecl, frozenset[str]]] = [] + seen: set[int] = set() + here = frozenset(w.name for w in decl.where_fns or ()) + stack: list[tuple[ast.FnDecl, frozenset[str]]] = [ + (w, here) for w in reversed(decl.where_fns or ()) + ] + while stack: + wfn, inherited = stack.pop() + if id(wfn) in seen: + continue + seen.add(id(wfn)) + scope = inherited | {w.name for w in wfn.where_fns or ()} + out.append((wfn, scope)) + if not wfn.forall_vars: + stack.extend( + (w, scope) for w in reversed(wfn.where_fns or ()) + ) + return out + @staticmethod def _flatten_where_fns(decl: ast.FnDecl) -> list[ast.FnDecl]: """Every ``where``-helper reachable from *decl*, at any depth (#978), diff --git a/vera/codegen/functions.py b/vera/codegen/functions.py index fa5c5f1c..06dbcd35 100644 --- a/vera/codegen/functions.py +++ b/vera/codegen/functions.py @@ -238,12 +238,18 @@ def _compile_fn( module_tables: ( tuple[SpanTypeTable | None, SpanTypeTable | None] | None ) = None, + where_scope: frozenset[str] = frozenset(), ) -> str | None: """Compile a single function to WAT. Returns the WAT function string, or None if not compilable (with a warning diagnostic). + *where_scope* (#1299) is the ``where``-helper names lexically in + scope in *decl*'s body; with the namespace this compile is running + under it decides which bare names the emitted body may treat as + denoting a user declaration. See ``_scoped_fn_names``. + *imported* is True when *decl* is an imported module body compiled into this flat WASM module (Pass 2.5 / 2.6). The checker's resolved- / target-type side-tables are keyed by span alone (``(line, col, end_line, @@ -442,6 +448,10 @@ def _compile_fn( self, "_generic_constrained_vars", None), ctor_to_adt=ctor_to_adt, known_fns=set(self._fn_sigs.keys()), + # #1299: the ownership predicate's table is the LEXICAL one — + # `known_fns` above stays flat for the guard rail, which asks + # whether a resolved target has a symbol, not whose name it is. + scoped_fns=self._scoped_fn_names(where_scope, decl.name), ctor_adt_tp_indices=getattr(self, "_ctor_adt_tp_indices", None), adt_tp_counts=getattr(self, "_adt_tp_counts", None), adt_tp_param_names=getattr(self, "_adt_tp_param_names", None), diff --git a/vera/codegen/modules.py b/vera/codegen/modules.py index b0919936..ced8a9f3 100644 --- a/vera/codegen/modules.py +++ b/vera/codegen/modules.py @@ -17,6 +17,7 @@ importer_occupied_bare_names, module_qualified_generic_names, module_qualified_generic_targets, + namespace_fn_names, public_generic_names, ) @@ -263,6 +264,19 @@ def _register_modules(self, program: ast.Program) -> None: mod.program, ) + # #1281: every module's top-level generic names, whatever their + # visibility. The collision rail below needs to know that BOTH sides + # of a name clash are generics before the ownership classification + # can say anything about them — a non-generic is emitted under the + # bare `$name` in Pass 2.5 and collides for real. + generics_by_path: dict[tuple[str, ...], frozenset[str]] = { + mod.path: frozenset( + tld.decl.name for tld in mod.program.declarations + if isinstance(tld.decl, ast.FnDecl) and tld.decl.forall_vars + ) + for mod in self._resolved_modules + } + # Provenance tracking for collision detection fn_provenance: dict[str, tuple[str, ...]] = {} adt_provenance: dict[str, tuple[str, ...]] = {} @@ -348,7 +362,10 @@ def _register_modules(self, program: ast.Program) -> None: # Collision detection: same name from different module if fn_name in fn_provenance: prev_path = fn_provenance[fn_name] - if prev_path != mod.path: + if prev_path != mod.path and not self._generics_cannot_collide( + fn_name, prev_path, mod.path, + generics_by_path, qualified_by_path, + ): self._emit_collision_error( program, fn_name, "Function", prev_path, mod.path, "E608", @@ -357,16 +374,31 @@ def _register_modules(self, program: ast.Program) -> None: else: fn_provenance[fn_name] = mod.path - # For bare-call injection: only public + in import filter is_public = vis_map.get(fn_name) == "public" in_filter = ( name_filter is None or fn_name in name_filter ) - if is_public and in_filter: + # Every module function — public or private, in filter or not + # — is registered, so the guard rail sees the symbols Pass + # 2.5/2.6 emit. #1281: except a QUALIFIED-ONLY generic, + # which emits nothing under its bare name. Its clones are + # `mod$$name$…`, so the bare key names no symbol; what + # it would do is hand a per-NAME consumer — the #1207 + # `MonoContext.fn_names` shadow guard, the return-type + # registries derived from these keys — whichever module + # happened to register first. + # + # DEFENCE IN DEPTH, not the thing that closes that: #1299's + # scope narrowing reaches the same consultors through the + # call site, and reverting this line and its + # `_fn_ret_type_exprs` twin leaves every suite and the whole + # conformance corpus green. It is kept because four + # consumers read these tables per NAME and only their current + # internals stop each from picking one, and it is pinned + # structurally — on this registry — in + # tests/test_module_generic_collision_1281.py. + if fn_name not in module_own_qualified: self._fn_sigs.setdefault(fn_name, sig) - # All module functions (including private helpers) get - # registered so the guard rail sees them as known - self._fn_sigs.setdefault(fn_name, sig) # #890: track importer visibility. A direct import contributes # its public, in-filter names to the importer's namespace; a # transitive-only module contributes nothing visible here even @@ -409,7 +441,23 @@ def _register_modules(self, program: ast.Program) -> None: canonical_ret = canonicalize_type_aliases( ret_te, temp._type_aliases, temp._type_alias_params, ) - self._fn_ret_type_exprs.setdefault(fn_name, canonical_ret) + # #1281: the bare key is withheld from a QUALIFIED-ONLY + # generic here for the same reason as in `_fn_sigs` above, + # and with the same standing — DEFENCE IN DEPTH. The + # invisible-declaration shape this registry can produce (the + # rewrite naming `idg$Bool` from a module generic's declared + # return where discovery named the cell's `idg$Int`, dropping + # the caller with [E602]) is closed by #1299's gate on + # `_declared_return_clone_name`, which asks the ownership + # predicate before reading this table at all. Reverting this + # line changes no suite and no conformance program; it is + # kept and pinned structurally, in its own cell, separate + # from the `_fn_sigs` one so a mutation to either cannot hide + # behind the other. The per-owner + # `_module_fn_ret_type_exprs` key below is unaffected; a + # `m::f` spelling still classifies by its resolved target. + if fn_name not in module_own_qualified: + self._fn_ret_type_exprs.setdefault(fn_name, canonical_ret) # #841 (PR #842 review round 2): also key by (module # path, name) so a module-qualified await classifies by # the RESOLVED target's return type. The bare-name @@ -785,6 +833,115 @@ def visible( *declared_adts.values()) if declared_adts else frozenset(main_own) return members + def _generics_cannot_collide( + self, + name: str, + path_a: tuple[str, ...], + path_b: tuple[str, ...], + generics_by_path: dict[tuple[str, ...], frozenset[str]], + qualified_by_path: dict[tuple[str, ...], set[str]], + ) -> bool: + """May two modules' same-named declarations share the namespace? (#1281) + + E608 exists because the flat compilation strategy emits every + imported function under one WASM name. A GENERIC emits nothing under + its bare name — only clones — and since #1274 the clone namespace is + chosen per OWNER: one that owns the importer's bare name mangles to + ``gen$Bool``, and one that does not (private, outside the filter, + shadowed by a local, or reached only transitively) mangles to + ``mod$$gen$Bool``. Two generics in different owner namespaces + overwrite nothing, and the rail refused them anyway. + + Three conditions, and all three are load-bearing: + + * **both declarations are top-level generics.** A non-generic is + emitted under the bare ``$name`` in Pass 2.5 whatever its + visibility, so two of them collide for real. + * **at most one owns the bare name.** Two directly-imported, + in-filter, public, unshadowed generics both mangle to ``gen$Bool`` + — the collision the rail is actually for. + * **no namespace can name both.** A module importing two + dependencies that each export ``gen``, and declaring none itself, + resolves its own bare ``gen`` to one of them — and the language has + not said which. Spec §8.5 covers local-shadows-import and + prescribes selective import for a two-import clash; it defines no + order between the two. Nor does the implementation: the CHECKER's + pick is not an order at all but a set-iteration artefact, and the + identical program accepts on one run and reports ``body has type + Bool`` on the next (measured 4/4 over eight runs; stable under a + fixed ``PYTHONHASHSEED`` and varying with the seed, and identical + whichever textual order the two imports are written in). Codegen's + ``module_qualified_generic_targets`` loop, by contrast, IS + positional — last import wins. Compiling the shape would therefore + pick a body by coin-flip, and the two generics are qualified-only + from the entry's point of view, so the ownership classification + alone would relax it. The loud refusal stays until the rule is + decided (#1304), which is what §8.5's design note already tells the + author to do (name the import). + + The ambiguity set comes from :meth:`_collect_namespace_fn_names`, the + same walk that decides which names each namespace can see for #1299 — + one derivation of one visibility rule, so the rail cannot relax + somewhere the scope says it must not. + """ + if not ( + name in generics_by_path.get(path_a, frozenset()) + and name in generics_by_path.get(path_b, frozenset()) + ): + return False + if name in self._ambiguous_imported_fn_names: + return False + owners = sum( + name not in qualified_by_path.get(path, set()) + for path in (path_a, path_b) + ) + return owners <= 1 + + def _collect_namespace_fn_names(self, program: ast.Program) -> None: + """Which FUNCTION names each namespace can name (#1299). + + The function-side twin of :meth:`_build_adt_membership`, and the same + rule: a namespace holds its OWN top-level declarations, whatever + their visibility, plus what it IMPORTS — public only, and only the + names an explicit import list mentions. That is the checker's view + of every module, rebuilt from the declarations codegen already holds, + so an unimported (or private) sibling's function is as opaque on this + side as it is on the checker's. Imports are read PER namespace and + never inherited, so a module reached only transitively from the entry + program contributes nothing to the entry's set (spec §8.6.4) while + still holding everything ITS own import list allows. + + Three consumers read the result, which is why the derivation is the + SHARED :func:`~vera.monomorphize.namespace_fn_names` rather than a + local walk: :meth:`~CodeGenerator._scoped_fn_names` narrows the flat + ``_fn_sigs`` registry with it before the #1284 ownership predicate + reads it, #1281's collision rail reads the ambiguity half, and the + VERIFIER narrows its discovery with the same tables (#1299). Two + walks over the same imports could disagree about a filter or a + visibility, and the two sides of the #732 differential would then + discover different clones. + + Called TWICE, and both times deliberately. The first call is before + ``_register_modules`` — which returns early for a single-file + program, and whose E608 rail needs the ambiguity half in hand — and + the second is after the prelude pass, once ``_prelude_fn_names`` is + populated, because the prelude's combinators are visible in every + namespace and the first call cannot know them yet. The derivation is + pure, so the second call simply replaces the first's answer; the + ambiguity half is identical either way (a prelude name is imported + from nowhere). Route three of #1299 (a ``forall`` parent's + ``where`` helper) involves no imports at all, so the entry program + needs its set whether or not any module exists. + """ + tables = namespace_fn_names( + program, + [(mod.path, mod.program) for mod in self._resolved_modules], + prelude=self._prelude_fn_names, + ) + self._namespace_tables = tables + self._namespace_fn_names = dict(tables.by_namespace) + self._ambiguous_imported_fn_names = tables.ambiguous + @staticmethod def _collect_local_fn_names(program: ast.Program) -> set[str]: """All locally-declared function names that occupy (or may occupy) a diff --git a/vera/codegen/monomorphize.py b/vera/codegen/monomorphize.py index a259c2b0..5bb72628 100644 --- a/vera/codegen/monomorphize.py +++ b/vera/codegen/monomorphize.py @@ -136,6 +136,14 @@ def _build_mono_context( # rewrite does. `_fn_sigs` — not `fn_ret_types` above, which # drops any name whose return WAT type has no Vera collapse. fn_names=frozenset(self._fn_sigs), + # #1299: and the visibility tables that narrow it per walked + # declaration. `fn_names` above is the flat registry — the guard + # rail needs every emitted symbol in it, including a module's + # private helpers — so on its own it claimed a bare `get` the + # entry program's body meant as the operation, and discovery + # named a clone from the invisible declaration's return type + # while the rewrite named one from the cell's. + namespace_fn_names=getattr(self, "_namespace_tables", None), # #1274 (F1): every (module, name) the Pass-0 classification made # qualified-only, so a rerouted `deep::gen(...)` is not mistaken for # an instantiation of the importer's own `gen`. @@ -251,21 +259,25 @@ def _monomorphize( # generic's contract lied. These decls carry their where-helpers both # nested and as separate entries; `instances` is a set, so the overlap # costs nothing. - seed_decls: list[ast.FnDecl] = [ - tld.decl for tld in program.declarations + # #1299: each declaration is walked in ITS OWN namespace, so a bare + # call is discovered against the names that body can actually see. + # The entry program's declarations answer to `None`; an imported body + # arrives already paired with its module path, which was previously + # discarded here. + seed_decls: list[tuple[tuple[str, ...] | None, ast.FnDecl]] = [ + (None, tld.decl) for tld in program.declarations if isinstance(tld.decl, ast.FnDecl) ] - seed_decls.extend( - fdecl for _mp, fdecl in getattr(self, "_imported_fn_decls", []) - ) - for decl in seed_decls: + seed_decls.extend(getattr(self, "_imported_fn_decls", [])) + for mod_path, decl in seed_decls: if not decl.forall_vars: - mono.collect_calls_in_node( - decl, generic_decls, ctor_to_adt, instances, - ) - self._collect_eq_full_type_names( - decl, mono, generic_decls, ctor_to_adt, - ) + with mono.namespace_scope(mod_path): + mono.collect_calls_in_node( + decl, generic_decls, ctor_to_adt, instances, + ) + self._collect_eq_full_type_names( + decl, mono, generic_decls, ctor_to_adt, + ) # Generate monomorphized FnDecls with transitive closure. # After generating the first round, scan the monomorphized bodies @@ -359,9 +371,15 @@ def _monomorphize( name: set() for name in generic_decls } for body in hoisted: - mono.collect_calls_in_node( - body, generic_decls, ctor_to_adt, found, - ) + # #1299: a clone belongs to the module its base was declared + # in, which `_mono_clone_origins` records (`None` for a local + # one — the entry namespace). + with mono.namespace_scope( + self._mono_clone_origins.get(body.name), + ): + mono.collect_calls_in_node( + body, generic_decls, ctor_to_adt, found, + ) for t_name, t_types in found.items(): for t_ct in sorted(t_types): # deterministic (see the seed) if (t_name, t_ct) not in seen: @@ -425,9 +443,12 @@ def _drain_generic_worklist( transitive: dict[str, set[tuple[str, ...]]] = { name: set() for name in generic_decls } - mono.collect_calls_in_node( - mono_fn, generic_decls, ctor_to_adt, transitive, - ) + with mono.namespace_scope( + self._mono_clone_origins.get(mono_fn.name), + ): + mono.collect_calls_in_node( + mono_fn, generic_decls, ctor_to_adt, transitive, + ) for t_name, t_types in transitive.items(): for t_ct in sorted(t_types): # deterministic order (see seed) if (t_name, t_ct) not in seen: @@ -559,9 +580,18 @@ def _instantiate_hoisted_generics( emitted: set[tuple[str, tuple[str, ...]]] = set() scan: list[ast.FnDecl] = list(bodies) while scan: - found = mono.collect_generic_helper_instances( - by_name, scan, ctor_to_adt, - ) + # #1299: the helper family's bodies are the PARENT clone's code, + # so their bare names resolve in the parent's namespace — the + # same `origin` the alias env below is built from. This leaf is + # the discovery walk BOTH sides drive directly; left unscoped it + # fell back to the flat table on both at once, so the two agreed + # while both typed a bare `get(())` from an invisible module + # declaration. Agreeing wrongly is invisible to a differential, + # which is why this one is pinned against the CHECKER's answer. + with mono.namespace_scope(origin): + found = mono.collect_generic_helper_instances( + by_name, scan, ctor_to_adt, + ) scan = [] for gen_name, concretes in found.items(): gen = by_name[gen_name] @@ -829,6 +859,7 @@ def _monomorphize_shadowed_module_generics( # back onto this shadowed worklist. self._chase_normal_transitive( clone, generic_decls, ctor_to_adt, mono, mono_decls, seen, + root_namespace=path, ) trans_shadow: dict[str, set[tuple[str, ...]]] = { name: set() for name in decls_by_name @@ -836,7 +867,9 @@ def _monomorphize_shadowed_module_generics( # #1274 (F1): this scan is keyed on THIS module's own generics, so a # `path::sibling(...)` here is the entry meant — see # `Monomorphizer.shadowed_module_scope`. - with mono.shadowed_module_scope(path): + # #1299: and its bare names resolve in that module's namespace too + # — this clone's body is that module's code. + with mono.shadowed_module_scope(path), mono.namespace_scope(path): mono.collect_calls_in_node( clone, decls_by_name, ctor_to_adt, trans_shadow, ) @@ -887,6 +920,7 @@ def _chase_normal_transitive( mono: Monomorphizer, mono_decls: list[ast.FnDecl], seen: set[tuple[str, tuple[str, ...]]], + root_namespace: tuple[str, ...] | None = None, ) -> None: """Emit the transitive closure of normal (unshadowed) clones reachable from a clone body scanned during shadowed emission. @@ -897,16 +931,27 @@ def _chase_normal_transitive( emitted — an ``unknown func`` at run. This re-runs the normal path's body-scan worklist rooted at ``root_fn`` (itself already emitted), feeding the shared ``seen`` set so nothing is emitted twice. + + *root_namespace* (#1299) is the module ``root_fn`` belongs to. It is + passed rather than looked up because a shadowed clone reaches here + under its PRE-rename name (``gen$Bool``, not + ``mod$lib$gen$Bool``), which is in no origin registry — and the + caller has the path in hand. Clones reached transitively from it get + their own base's origin, which they are registered under. """ - stack: list[ast.FnDecl] = [root_fn] + stack: list[tuple[ast.FnDecl, tuple[str, ...] | None]] = [ + (root_fn, root_namespace), + ] while stack: - fn = stack.pop() + fn, namespace = stack.pop() transitive: dict[str, set[tuple[str, ...]]] = { name: set() for name in generic_decls } - mono.collect_calls_in_node( - fn, generic_decls, ctor_to_adt, transitive, - ) + # #1299: each clone in ITS base's namespace (see the sibling scan). + with mono.namespace_scope(namespace): + mono.collect_calls_in_node( + fn, generic_decls, ctor_to_adt, transitive, + ) for t_name, t_types in transitive.items(): for t_ct in sorted(t_types): if (t_name, t_ct) in seen: @@ -928,7 +973,9 @@ def _chase_normal_transitive( # concrete-free `_emitted_instances` key matching the verifier. self._clone_base_chain[t_fn.name] = t_name self._emitted_instances.add((t_name, t_ct)) - stack.append(t_fn) + stack.append( + (t_fn, self._mono_clone_origins.get(t_fn.name)), + ) @staticmethod def _mono_shadowed_name( diff --git a/vera/monomorphize.py b/vera/monomorphize.py index b278028a..f1675925 100644 --- a/vera/monomorphize.py +++ b/vera/monomorphize.py @@ -757,6 +757,112 @@ def module_qualified_generic_targets( return targets +@dataclass(frozen=True) +class NamespaceFnNames: + """Which bare function names each namespace can NAME (#1299/#1281). + + ``by_namespace`` maps a module path — ``None`` for the entry program — to + the bare SOURCE function names a body compiled in that namespace may + resolve a bare call to: its own top-level declarations, whatever their + visibility, plus the PUBLIC declarations its OWN import list admits. + That is the checker's view of every module, and imports are read per + namespace and never inherited, so a module reached only transitively + from the entry program contributes nothing to the entry's set (spec + §8.6.4) while still holding everything its own imports allow. + + ``ambiguous`` is the bare names some namespace could resolve to more than + one dependency's declaration, with no declaration of its own to settle it + — the shape spec §8.5 leaves undefined (#1304). + + One derivation because there are three consumers and they must not + disagree: codegen narrows its per-declaration ownership table with it + (``_scoped_fn_names``), codegen's E608 rail reads the ambiguity half, and + the verifier narrows discovery with it. Two walks over the same imports + could differ about a filter or a visibility, and the two sides of the + #732 differential would then discover different clones. + """ + + by_namespace: Mapping[tuple[str, ...] | None, frozenset[str]] + ambiguous: frozenset[str] + + def visible(self, path: tuple[str, ...] | None) -> frozenset[str]: + """The names *path*'s namespace can NAME; empty for an unknown path.""" + return self.by_namespace.get(path, frozenset()) + + +def namespace_fn_names( + entry: ast.Program, + modules: Iterable[tuple[tuple[str, ...], ast.Program]], + prelude: Iterable[str] = (), +) -> NamespaceFnNames: + """Build the per-namespace visibility tables (see :class:`NamespaceFnNames`). + + Reads each program's declarations as written. The Pass-0 transforms (the + #991 hoist, the #1014 qualification) only ADD ``$``-qualified top-level + declarations, and ``$`` cannot occur in a Vera identifier + (``LOWER_IDENT``), so no bare source name enters or leaves either set — + which is what lets codegen call this on its post-transform programs and + the verifier on its pre-transform ones and still get the same answer. + + *prelude* is the injected combinators' names. They belong to EVERY + namespace — a module's body may call ``option_map`` exactly as the entry + program may — and they are supplied separately rather than read off the + entry program because the two consumers inject them at different passes: + the verifier's discovery copy is post-``inject_prelude`` while codegen's + tables are built at Pass 0.5, before the prelude is registered. Passing + them makes the result independent of WHEN it is called, which is the + property the two sides need and the one + ``test_discovery_scopes_agree_between_the_two_sides`` checks. They also + join the "declared here" set for the ambiguity test, so a program whose + entry declarations already contain them (the verifier's) and one whose do + not (codegen's) still answer identically. + """ + public_fns: dict[tuple[str, ...], frozenset[str]] = {} + module_list = list(modules) + prelude_names = frozenset(prelude) + for path, prog in module_list: + public_fns[path] = frozenset( + tld.decl.name for tld in prog.declarations + if isinstance(tld.decl, ast.FnDecl) + and (tld.visibility or "private") == "public" + ) + + ambiguous: set[str] = set() + + def visible(prog: ast.Program) -> frozenset[str]: + own = { + tld.decl.name for tld in prog.declarations + if isinstance(tld.decl, ast.FnDecl) + } | prelude_names + names = set(own) + # Which dependency each importable name came from. A name this + # namespace declares ITSELF is never ambiguous however many + # dependencies also export it — the local declaration owns every bare + # call here (spec §8.5.2). + sources: dict[str, set[tuple[str, ...]]] = {} + for imp in prog.imports: + dep = tuple(imp.path) + exported = public_fns.get(dep) + if exported is None: + continue + for name in exported: + if imp.names is None or name in imp.names: + names.add(name) + sources.setdefault(name, set()).add(dep) + ambiguous.update( + name for name, deps in sources.items() + if len(deps) > 1 and name not in own + ) + return frozenset(names) + + by_namespace: dict[tuple[str, ...] | None, frozenset[str]] = { + None: visible(entry), + } + for path, prog in module_list: + by_namespace[path] = visible(prog) + return NamespaceFnNames(by_namespace, frozenset(ambiguous)) + + def public_generic_names(module_program: ast.Program) -> set[str]: """The module's PUBLIC top-level generic names — what a dependent can name at all, before that dependent's own import filter narrows it.""" @@ -1380,6 +1486,17 @@ class MonoContext: alias_env: AliasEnv = EMPTY_ALIAS_ENV # #1207: the consumer's own function-name table (see the docstring). fn_names: frozenset[str] = frozenset() + # #1299: the per-namespace visibility tables ``fn_names`` is NARROWED by + # while a declaration is being walked. ``fn_names`` is flat — every + # symbol the consumer registered, including a module's private helpers — + # so on its own it answers "user-owned" for a name the walked body cannot + # see, and discovery then names a clone from that invisible declaration's + # return type while the WASM rewrite names one from the operation's. The + # narrowing is entered per declaration by :meth:`namespace_scope`, which + # each consumer wraps its seed walk in. Defaulted ``None``: a consumer + # that has not been threaded, or a walk entered outside any scope, keeps + # the flat answer — never an EMPTY one, which would claim no name at all. + namespace_fn_names: NamespaceFnNames | None = None # #1274 (F1): ``(module path, name)`` pairs whose generic is QUALIFIED-ONLY # — reached under ``mod$$name``, never under the bare name. A # ``ModuleCall`` to one of these must NOT be discovered as an instantiation @@ -1432,6 +1549,60 @@ def __init__(self, ctx: MonoContext) -> None: # keyed on, or ``None`` outside such a scan. Walk state, like the two # above it — see `shadowed_module_scope`. self._shadowed_scan_path: tuple[str, ...] | None = None + # #1299: the bare names visible where the walk currently is — the + # namespace's own (see `namespace_scope`) plus the `where` helpers of + # every enclosing function, accumulated by `collect_calls_in_node` + # exactly as `_scope_type_vars` is. ``None`` means no scope was + # entered, and `_bare_call_is_user_fn` then answers from the flat + # `ctx.fn_names` alone — the pre-#1299 behaviour. + self._scope_fn_names: frozenset[str] | None = None + + @contextlib.contextmanager + def namespace_scope( + self, path: tuple[str, ...] | None, + ) -> Iterator[None]: + """Walk declarations that resolve bare names in *path*'s namespace. + + Inside this scope :meth:`_bare_call_is_user_fn` narrows the consumer's + flat ``fn_names`` to what that namespace can actually NAME, so an + imported module's private declaration stops claiming a bare ``get`` + the entry program's body meant as the ``State`` operation (#1299). + + A no-op when the consumer supplied no visibility tables: the walk then + keeps answering from the flat table, which is what every consumer did + before this existed. ``path=None`` is the entry program's namespace, + which is a real answer rather than an absence — a body there sees the + entry's own declarations and its direct imports' public, in-filter + names, and nothing else. + """ + if self.ctx.namespace_fn_names is None: + yield + return + saved = self._scope_fn_names + self._scope_fn_names = self.ctx.namespace_fn_names.visible(path) + try: + yield + finally: + self._scope_fn_names = saved + + def _bare_call_is_user_fn(self, name: str) -> bool: + """Discovery's leg of :func:`~vera.slots.bare_call_denotes_user_fn`. + + The consumer's own table AND the lexical scope the walk is in — a + narrowing, never a widening, so a consumer that entered no scope (or + supplied no tables) gets exactly the flat answer it got before. + + Every ``$``-bearing name is admitted whatever the scope says, on the + same reasoning as codegen's ``_scoped_fn_names``: ``$`` is outside + ``LOWER_IDENT``, so a mangled name is never what a bare source call + spells, and a clone name reaching here after a rewrite must keep + answering as the declaration it was minted from. + """ + if not bare_call_denotes_user_fn(name, self.ctx.fn_names): + return False + if self._scope_fn_names is None or "$" in name: + return True + return name in self._scope_fn_names @contextlib.contextmanager def shadowed_module_scope( @@ -1512,6 +1683,25 @@ def collect_calls_in_node( # so the set accumulates down the nesting exactly as the binders do. saved_vars = self._scope_type_vars self._scope_type_vars = saved_vars | frozenset(fn.forall_vars or ()) + # #1299: and this function's own `where` helpers join the visible-name + # scope for the same subtree, for the same reason the binders do — a + # helper is in its parent's scope and in its siblings', and in nobody + # else's. Only inside a `namespace_scope`: outside one the walk keeps + # the flat answer, and starting to accumulate would silently turn that + # into an almost-empty scope. + # + # The walked function's OWN name is deliberately NOT added. A + # top-level one is already in its namespace's set, and a helper or a + # clone is `$`-qualified and admitted by that rule — so adding it + # changes no answer, and it made the scope carry the enclosing + # CLONE's name, which differs between the two sides for one helper + # walked under two instantiations. Textually divergent, semantically + # identical, and the differential could not tell those apart. + saved_names = self._scope_fn_names + if saved_names is not None: + self._scope_fn_names = saved_names | { + wfn.name for wfn in (fn.where_fns or ()) + } try: self._collect_calls_in_node_scoped( fn, generic_decls, ctor_to_adt, instances, @@ -1519,6 +1709,7 @@ def collect_calls_in_node( finally: self._op_result_types = saved_ops self._scope_type_vars = saved_vars + self._scope_fn_names = saved_names def _binds_a_type_var( self, @@ -2173,9 +2364,14 @@ def _infer_vera_type_name( # the handler-expression merge did not, so a user `fn get` called # under a `handle[State]` named the CELL's clone here and the # user function's return type there. + # #1299: over the LEXICAL scope the walk is in, not the flat table. + # An imported module's `private fn get` is in `ctx.fn_names` — the + # guard rail needs its symbol — and claimed this call site, so + # discovery named a clone from that declaration's return type + # (`idg$Bool`) while the WASM rewrite named one from the cell's + # (`idg$Int`): check-green source that failed to load. if (isinstance(expr, ast.FnCall) - and not bare_call_denotes_user_fn( - expr.name, self.ctx.fn_names) + and not self._bare_call_is_user_fn(expr.name) and expr.name in self._op_result_types): return self._op_result_types[expr.name] if isinstance(expr, ast.FnCall) and generic_decls: diff --git a/vera/slots.py b/vera/slots.py index b6cc4f41..e2873f05 100644 --- a/vera/slots.py +++ b/vera/slots.py @@ -71,9 +71,10 @@ def bare_call_denotes_user_fn( commutativity walk, so the row it reasons about is the row the resolution above bound; * ``_translate_call`` in :mod:`vera.wasm.calls` — the bare dispatch, over - codegen's flat ``_fn_sigs`` mirror (``_known_fns``): a user-owned name - skips the clause-inline registry, the host-cell intrinsics, and the - #1233 addressability gate, and lowers as the ordinary call it is; + codegen's ``_scoped_fns`` (``_fn_sigs`` narrowed to the compiling + declaration's lexical scope, #1299): a user-owned name skips the + clause-inline registry, the host-cell intrinsics, and the #1233 + addressability gate, and lowers as the ordinary call it is; * the three bare-``FnCall`` inference sites in :mod:`vera.wasm.inference` and :mod:`vera.wasm.context`, so a shadowed name is typed from the function table rather than from the operation's result registry; @@ -83,7 +84,10 @@ def bare_call_denotes_user_fn( owns the name; * :class:`~vera.monomorphize.Monomorphizer`'s discovery walk, over ``MonoContext.fn_names``, so the clone it discovers for a ``get(())`` - in a value position is the clone the rewrite emits. + in a value position is the clone the rewrite emits. That table is + program-wide rather than per-scope, so a name it must not claim is + kept OUT of it instead: a qualified-only module generic contributes + no bare ``_fn_sigs`` entry at all (#1281). What this deliberately does NOT gate is the op REGISTRIES themselves. "Whose name is this?" and "which cell does the operation reach?" are two @@ -94,33 +98,33 @@ def bare_call_denotes_user_fn( reaching their cell in a program that also declares ``fn get``. *user_fn_names* is a membership view, not a fixed set, so each consumer - supplies the table it actually resolves against — the checker a lexical - scope walk, codegen its flat signature keys. One rule over two tables - is one answer only where the tables agree, and they do not everywhere: - codegen's ``_fn_sigs`` keys a name the call site cannot SEE under its - BARE name, so this predicate answers "user-owned" where the checker - resolved the operation. Three routes are demonstrated — a ``private fn - get`` in an imported module, a public one a selective import excludes, - and a ``where`` helper of a ``forall`` parent — and all three are one - defect, tracked as #1299. It is a property of the TABLE rather than of - the rule, and the fix belongs to ``_known_fns``, which has to carry the - names visible in the compiling declaration's LEXICAL scope. - - The ``where``-helper route is the one worth spelling out, because the - other two make it tempting to assume helpers are safe. Under a - NON-generic parent they are: #1015 and the non-generic hoist move the - helper out of the bare namespace before registration (a local one is - ``holder$where$get``) and rewrite its holder's call sites with it, so it - shadows the name in its own body and nowhere else — the checker's - lexical answer exactly. Under a GENERIC parent the helper keeps a bare - ``_fn_sigs`` key beside its clone-qualified one (measured: ``helper`` - and ``id`` in ``tests/conformance/ch09_generic_where_helper.vera``), so - a sibling's bare ``get(())`` under a ``State`` row is claimed by a - helper it cannot see. That route is always LOUD rather than silently - wrong, and for a reason worth keeping: the bare key exists in the - signature table while no bare SYMBOL is emitted — the helper is only - ever ``holder$Bool$where$get`` — so the call lands on ``unknown func: - failed to find name $get`` at WAT assembly, with no E-code. + supplies the table it actually resolves against. One rule over two + tables is one answer only where both tables are SCOPES, and codegen's + was not: it passed a flat mirror of every symbol the whole compilation + absorbed, which keys a name the call site cannot SEE under its bare + name, so this predicate answered "user-owned" where the checker had + resolved the operation (#1299). Codegen now passes ``_scoped_fns`` — + ``_fn_sigs`` narrowed to the compiling declaration's lexical scope by + ``CodeGenerator._scoped_fn_names`` — while the flat registry stays + behind ``_known_fns`` for the guard rail, which asks the different and + genuinely flat question "does this resolved target have a symbol?". + + Four routes reached that divergence, and the narrowing closes them + together because they were one defect: an imported module's ``private + fn get``, a public one a selective import excludes, a ``where`` helper + of a ``forall`` parent, and — through the INTRINSIC gate rather than + the op one — the ability operation ``show``, which E151 does not + reserve. The ``where``-helper route is the one worth spelling out, + because the other three make it tempting to think module scope is + enough. Under a NON-generic parent a helper is safe: #1015 and the + non-generic hoist move it out of the bare namespace before registration + (a local one is ``holder$where$get``) and rewrite its holder's call + sites with it, so it shadows the name in its own body and nowhere else. + Under a GENERIC parent it keeps a bare ``_fn_sigs`` key beside its + clone-qualified one (measured: ``helper`` and ``id`` in + ``tests/conformance/ch09_generic_where_helper.vera``) — and it IS in + the module, so only the LEXICAL rule excludes it from a sibling's + scope while leaving it in its own parent's. """ return name in user_fn_names diff --git a/vera/verifier.py b/vera/verifier.py index 18ce1e60..83fa83fd 100644 --- a/vera/verifier.py +++ b/vera/verifier.py @@ -31,6 +31,7 @@ importer_occupied_bare_names, module_qualified_generic_names, module_qualified_generic_targets, + namespace_fn_names, public_generic_names, qualify_nested_generic_decls, reroute_module_qualified_generic_calls, @@ -334,6 +335,12 @@ def __init__( # discovery key `_instances` / `generic_decls` use. A key absent from # here is a main-file generic. self._generic_origins: dict[str, tuple[str, ...]] = {} + # #1299: the names `inject_prelude` added to the discovery copy. Fed + # to the shared `namespace_fn_names` derivation so the tables carry + # the prelude for EVERY namespace, and so this side's answer does not + # depend on the discovery copy already holding those declarations + # while codegen's program does not. + self._disc_prelude_fn_names: frozenset[str] = frozenset() # See the class-level defaults: the DEFINING module's env (#1208) and # source + file (#1220) while an imported generic's clone is verified, # `None` otherwise. @@ -1689,6 +1696,23 @@ def record_fn_ret_type(fn: ast.FnDecl) -> None: fn_ret_type_exprs=fn_ret_type_exprs, # #1207: see the `fn_names` comment above. fn_names=frozenset(fn_names), + # #1299: the per-namespace visibility tables that narrow it while + # a declaration is walked, from the SAME shared derivation codegen + # drives. The two must narrow IDENTICALLY, and + # `test_discovery_scopes_agree_between_the_two_sides` compares + # them per declaration rather than leaving it to this comment — + # they were measurably different in two ways before it existed. + # Read from the PRE-transform module ASTs, which changes no + # answer: the Pass-0 transforms only add `$`-qualified + # declarations, and `$` cannot occur in a Vera identifier. The + # prelude is passed separately for the same reason: this program + # is post-injection and codegen's is not, so reading it off the + # declarations would make the answer depend on the caller. + namespace_fn_names=namespace_fn_names( + disc_program, + [(mod.path, mod.program) for mod in self._resolved_modules], + prelude=self._disc_prelude_fn_names, + ), ) @staticmethod @@ -1907,7 +1931,20 @@ def _collect_instantiations( # injection, mirroring codegen's ordering (qualify at Pass 0, prelude # injected later), so prelude decls are unqualified on both sides. disc = qualify_nested_generic_decls(disc) + # #1299: the names the prelude adds, as a set. They belong to every + # namespace, so `namespace_fn_names` takes them separately rather + # than reading them off this program — codegen's tables are built + # before its own injection, and the shared derivation has to answer + # the same whichever side calls it. + _pre_prelude_fns = { + tld.decl.name for tld in disc.declarations + if isinstance(tld.decl, ast.FnDecl) + } inject_prelude(disc) + self._disc_prelude_fn_names = frozenset( + tld.decl.name for tld in disc.declarations + if isinstance(tld.decl, ast.FnDecl) + ) - _pre_prelude_fns generic_decls: dict[str, ast.FnDecl] = {} for tld in disc.declarations: @@ -2021,6 +2058,7 @@ def _collect_instantiations( def collect_calls_in_fn( fn: ast.FnDecl, into: dict[str, set[tuple[str, ...]]], + namespace: tuple[str, ...] | None = None, ) -> None: # Delegate to the SHARED node-level walk (body + contract clauses + # `where` helpers). Both this discovery and codegen's Pass 1.5 drive @@ -2029,7 +2067,13 @@ def collect_calls_in_fn( # a contract predicate (`ensures(is_valid(@T.result))`) or a # where-helper body is found by both, or by neither, never just one # (PR #767 review). - mono.collect_calls_in_node(fn, generic_decls, ctor_to_adt, into) + # #1299: in *namespace*'s scope, so a bare call is discovered + # against the names that body can see — codegen's Pass 1.5 enters + # the identical scope for the identical declaration. + with mono.namespace_scope(namespace): + mono.collect_calls_in_node( + fn, generic_decls, ctor_to_adt, into, + ) # Seed from non-generic bodies — the main program AND every resolved # module (its qualified copy), so an imported ``compute``'s body call to @@ -2039,11 +2083,26 @@ def collect_calls_in_fn( seed: dict[str, set[tuple[str, ...]]] = { name: set() for name in generic_decls } - for seed_program in (disc, *qualified_module_programs): + # #1299: paired with the namespace each program's bodies resolve in — + # `None` for the entry program, the module's own path for a module's + # qualified copy. `qualified_module_programs` is built parallel to + # `_resolved_modules` just above, so the pairing is positional and the + # `strict=True` zip keeps it that way. + seed_namespaces: list[tuple[tuple[str, ...] | None, ast.Program]] = [ + (None, disc), + *( + (mod.path, qmod) + for mod, qmod in zip( + self._resolved_modules, qualified_module_programs, + strict=True, + ) + ), + ] + for namespace, seed_program in seed_namespaces: for tld in seed_program.declarations: decl = tld.decl if isinstance(decl, ast.FnDecl) and not decl.forall_vars: - collect_calls_in_fn(decl, seed) + collect_calls_in_fn(decl, seed, namespace) # #1002: nested generic-under-generic helpers keyed by their # concrete-FREE lexical chain (``parent$where$outer$where$ginner``) — the @@ -2084,10 +2143,16 @@ def record_nested(clone: ast.FnDecl, base_chain: str) -> None: # every such instantiation while codegen emitted it — a false # Tier-1 the moment codegen's own rescan landed. scan: list[ast.FnDecl] = [clone] + # #1299: the helper family resolves bare names in the module the + # CHAIN is declared in — the same key `env` above was resolved + # from. Codegen's `_instantiate_hoisted_generics` enters the + # identical scope around the identical leaf. + helper_ns = self._origin_module_for_generic(base_chain) while scan: - found = mono.collect_generic_helper_instances( - helpers, scan, ctor_to_adt, - ) + with mono.namespace_scope(helper_ns): + found = mono.collect_generic_helper_instances( + helpers, scan, ctor_to_adt, + ) scan = [] for h_name, h_cts in found.items(): chain_key = f"{base_chain}$where${h_name}" @@ -2107,7 +2172,13 @@ def record_nested(clone: ast.FnDecl, base_chain: str) -> None: top: dict[str, set[tuple[str, ...]]] = { name: set() for name in generic_decls } - collect_calls_in_fn(h_clone, top) + # #1299: a nested helper's clone belongs to the + # module its CHAIN is recorded under, the same key + # `env` above was resolved from. + collect_calls_in_fn( + h_clone, top, + self._origin_module_for_generic(chain_key), + ) for t_name, t_cts in top.items(): pending_top.extend( (t_name, t_ct) for t_ct in t_cts @@ -2136,7 +2207,13 @@ def record_nested(clone: ast.FnDecl, base_chain: str) -> None: transitive: dict[str, set[tuple[str, ...]]] = { name: set() for name in generic_decls } - collect_calls_in_fn(mono_fn, transitive) + # #1299: this clone's body is its BASE's module's code, so its + # bare names resolve there — the mirror of codegen's + # `_drain_generic_worklist` scope. `fn_name` is already the base + # key, so it goes to `_origin_module_for_generic` whole. + collect_calls_in_fn( + mono_fn, transitive, self._origin_module_for_generic(fn_name), + ) for t_name, t_types in transitive.items(): for t_ct in t_types: if (t_name, t_ct) not in discovered: @@ -2333,6 +2410,7 @@ def walk_seed(node: object) -> None: # Unshadowed transitive generics → chase full closure into `result`. self._chase_normal_from_clone( clone, generic_decls, ctor_to_adt, mono, normal_seen, result, + root_namespace=spath, ) # Same-module shadowed siblings → queue back. sib_decls = shadowed[spath] @@ -2340,7 +2418,12 @@ def walk_seed(node: object) -> None: name: set() for name in sib_decls } # #1274 (F1): mirrors codegen's scope around the identical scan. - with mono.shadowed_module_scope(spath): + # #1299: and its namespace scope — this clone's body is that + # module's code, so its bare names resolve there. + with ( + mono.shadowed_module_scope(spath), + mono.namespace_scope(spath), + ): mono.collect_calls_in_node( clone, sib_decls, ctor_to_adt, trans_shadow, ) @@ -2358,28 +2441,46 @@ def _chase_normal_from_clone( mono: Monomorphizer, normal_seen: set[tuple[str, tuple[str, ...]]], result: dict[str, set[tuple[str, ...]]], + root_namespace: tuple[str, ...] | None = None, ) -> None: """Add the transitive closure of unshadowed clones reachable from a shadowed clone body into ``result`` (verifier mirror of codegen's - ``_chase_normal_transitive``).""" - stack: list[ast.FnDecl] = [root_fn] + ``_chase_normal_transitive``). + + *root_namespace* (#1299) is the module ``root_fn`` belongs to, passed + rather than looked up for the reason codegen's twin gives: a shadowed + clone arrives under its PRE-rename name, which is in no origin + registry. Clones reached transitively use their own base's origin, + resolved through :meth:`_origin_module_for_generic` — that walk + exists because a base can be a lexical CHAIN whose origin is recorded + against an ANCESTOR (``mod$ng$outer$where$mid``), and a raw + dictionary lookup misses exactly those (the miss #1208 round 2 found + on the naming side). + """ + stack: list[tuple[ast.FnDecl, tuple[str, ...] | None]] = [ + (root_fn, root_namespace), + ] while stack: - fn = stack.pop() + fn, namespace = stack.pop() transitive: dict[str, set[tuple[str, ...]]] = { name: set() for name in generic_decls } - mono.collect_calls_in_node( - fn, generic_decls, ctor_to_adt, transitive, - ) + with mono.namespace_scope(namespace): + mono.collect_calls_in_node( + fn, generic_decls, ctor_to_adt, transitive, + ) for t_name, t_types in transitive.items(): for t_ct in t_types: if (t_name, t_ct) in normal_seen: continue normal_seen.add((t_name, t_ct)) result.setdefault(t_name, set()).add(t_ct) - stack.append(mono.monomorphize_fn( - generic_decls[t_name], t_ct, - self._alias_env_for_generic(t_name), # #1208 + stack.append(( + mono.monomorphize_fn( + generic_decls[t_name], t_ct, + self._alias_env_for_generic(t_name), # #1208 + ), + self._origin_module_for_generic(t_name), )) @property diff --git a/vera/wasm/calls.py b/vera/wasm/calls.py index 0737f828..45dbd29b 100644 --- a/vera/wasm/calls.py +++ b/vera/wasm/calls.py @@ -53,8 +53,10 @@ def _translate_call( # not mistakenly compiled as the array-length built-in. Same # ownership rule the effect-op dispatch below applies, over the # same table (#1284); spelled through the shared predicate so a - # change to the rule reaches both. - if not bare_call_denotes_user_fn(call.name, self._known_fns): + # change to the rule reaches both. The table is the LEXICAL one + # (#1299) — an intrinsic must not be displaced by a declaration + # this call site cannot see, any more than an operation must. + if not bare_call_denotes_user_fn(call.name, self._scoped_fns): if call.name == "array_length" and len(call.args) == 1: return self._translate_array_length(call.args[0], env) if call.name == "string_length" and len(call.args) == 1: diff --git a/vera/wasm/context.py b/vera/wasm/context.py index 1c3b7f5f..47278e53 100644 --- a/vera/wasm/context.py +++ b/vera/wasm/context.py @@ -99,6 +99,7 @@ def __init__( generic_constrained_vars: dict[str, frozenset[str]] | None = None, ctor_to_adt: dict[str, str] | None = None, known_fns: set[str] | None = None, + scoped_fns: set[str] | None = None, ctor_adt_tp_indices: dict[str, tuple[int | None, ...]] | None = None, adt_tp_counts: dict[str, int] | None = None, adt_tp_param_names: dict[str, tuple[str, ...]] | None = None, @@ -221,13 +222,25 @@ def __init__( ) # Constructor name → ADT name reverse mapping self._ctor_to_adt: dict[str, str] = ctor_to_adt or {} - # Known locally-defined function names (for cross-module guard rail, - # and — #1284 — codegen's leg of the bare-call ownership predicate: - # this is the flat mirror of `_fn_sigs` the checker's lexical scope - # is compared against). Empty when a context is built without one, - # which answers "no name is shadowed" — the pre-#1284 behaviour, and - # the safe default for a context that compiles no user declarations. + # Every WASM symbol this compilation registered — the REGISTRATION + # question, and only that: `_translate_call`'s guard rail asks whether + # a RESOLVED call target (already mono-mangled, already `mod$…` + # rerouted) has an implementation to land on. Flat by nature; a + # symbol emitted for some other namespace is still a symbol. self._known_fns: set[str] = known_fns or set() + # The names visible in the compiling declaration's LEXICAL scope — + # #1284's ownership question, which is a different one (#1299). + # Splitting them is the fix: one table answers "does this symbol + # exist?", the other "whose declaration does this bare name denote + # HERE?", and answering the second with the first is what let an + # invisible import claim a call site's `get`. Defaults to + # ``known_fns`` so a context built without one keeps the flat + # answer rather than silently owning NO name — an empty scope would + # route every bare call to the op registries, which is the opposite + # error and a far louder one. + self._scoped_fns: set[str] = ( + self._known_fns if scoped_fns is None else scoped_fns + ) # Per-field ADT type-param indices for sparse constructors (e.g. Err → (1,)) self._ctor_adt_tp_indices: dict[str, tuple[int | None, ...]] = ( ctor_adt_tp_indices or {} @@ -669,24 +682,30 @@ def _bare_call_denotes_op(self, name: str) -> bool: """Is a BARE call to *name* here the effect operation? (#1284) Codegen's leg of :func:`~vera.slots.bare_call_denotes_user_fn`, over - the flat ``_fn_sigs`` mirror. Every bare-call site that consults an - op registry asks this first, so a name the checker resolved to a user - declaration is lowered as the ordinary call the checker typed: the - clause-inline dispatch, the host-cell intrinsics, the #1233 - addressability gate, the three result-type inference sites, and - ``_handler_always_throws``'s ``throw_installed`` question, which is - the same one for ``Exn``'s operation. + ``_scoped_fns`` — the names visible in the compiling declaration's + LEXICAL scope, which is the table the checker resolves against. + Every bare-call site that consults an op registry asks this first, + so a name the checker resolved to a user declaration is lowered as + the ordinary call the checker typed: the clause-inline dispatch, the + host-cell intrinsics, the #1233 addressability gate, the three + result-type inference sites, and ``_handler_always_throws``'s + ``throw_installed`` question, which is the same one for ``Exn``'s + operation. Not for the QUALIFIED spelling: ``State.get(())`` names the effect, so no declaration can shadow it and the registries answer directly. - The ``_fn_sigs`` mirror is not scope-accurate — it keys a name the - call site cannot see under its bare name (an invisible import, or a - ``where`` helper of a ``forall`` parent), so this can answer - "user-owned" where the checker resolved the operation (#1299). The - rule is right and the table is wrong; the fix is that table's. + NOT ``_known_fns`` (#1299). That set is the registration table the + guard rail reads, and it is flat by construction — every symbol the + whole compilation absorbed, including a module's ``private fn get``, + a public one a selective import excludes, and the bare key a + ``forall`` parent's ``where`` helper keeps beside its + clone-qualified one. Asked over it, this predicate answered + "user-owned" at a site where the checker had resolved the operation: + check-green source ran the invisible declaration's body where the + widths agreed, and failed to load where they did not. """ - return not bare_call_denotes_user_fn(name, self._known_fns) + return not bare_call_denotes_user_fn(name, self._scoped_fns) def alloc_param(self) -> int: """Allocate a parameter slot (already in WASM signature). diff --git a/vera/wasm/inference.py b/vera/wasm/inference.py index dddff2ea..b082f1ed 100644 --- a/vera/wasm/inference.py +++ b/vera/wasm/inference.py @@ -13,7 +13,11 @@ resolve_fn_type_alias, substitute_type_vars, ) -from vera.slots import family_fallback_name, type_expr_slot_name +from vera.slots import ( + bare_call_denotes_user_fn, + family_fallback_name, + type_expr_slot_name, +) from vera.wasm.helpers import _element_wasm_type, state_type_arg # `substitute_type_vars` was relocated to `vera.monomorphize` (the codegen-free @@ -1341,9 +1345,20 @@ def _declared_return_clone_name(self, call: ast.FnCall) -> str | None: the clone-naming path must consult THIS method instead. Returns ``None`` for builtins, generics, and fns with no NamedType return, so the caller falls back to ``_infer_vera_type``. + + Gated on the #1284 ownership predicate over the LEXICAL scope + (#1299). ``_fn_ret_type_exprs`` is flat — it holds every declaration + the compilation absorbed, including an imported module's private one + — and this override BEATS ``_infer_vera_type``, so an invisible + ``fn get(@Unit -> @Bool)`` named the clone ``idg$Bool`` at a call site + where the general inference had correctly answered the ``State`` + cell's ``Int``. A name no visible declaration owns contributes no + declared return: the caller falls back, and reaches the operation. """ if call.name in self._generic_fn_info: return None + if not bare_call_denotes_user_fn(call.name, self._scoped_fns): + return None return declared_return_clone_key( self._fn_ret_type_exprs.get(call.name)) From 7c41138d1ea491e8bb04ac2c60e659effb18204d Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 08:41:49 +0100 Subject: [PATCH 20/69] Promote the two shapes the rails used to refuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six conformance programs for the two shapes that changed answer. `ch08_module_generic_diamond` is the E608 diamond: `mid1` declares a private `forall fn gen` and `mid2` bare-calls the public `gen` of the `base` both import. Each door answers its own module's generic, 555 + 111, where the pair used to be refused outright. `ch07_invisible_import_op_name` is the scope one: a bare `get(())` under a `State` row, beside an imported module's private `get`. Spec §7.4 resolves it against the call site's scope, so it is the operation and the cell's 42007 is the answer; the module's own `touch` is the control in the same program, since its `get` IS in its scope and must keep reaching it. Co-Authored-By: Claude --- .../ch07_invisible_import_op_name.vera | 60 ++++++++ .../ch07_invisible_import_op_name_lib.vera | 25 ++++ .../ch08_module_generic_diamond.vera | 18 +++ .../ch08_module_generic_diamond_base.vera | 13 ++ .../ch08_module_generic_diamond_mid1.vera | 23 +++ .../ch08_module_generic_diamond_mid2.vera | 15 ++ .../ch09_invisible_import_ability_op.vera | 28 ++++ .../ch09_invisible_import_ability_op_lib.vera | 23 +++ .../ch09_nested_helper_family_op_name.vera | 51 +++++++ ...ch09_nested_helper_family_op_name_lib.vera | 24 +++ tests/conformance/manifest.json | 138 ++++++++++++++++++ 11 files changed, 418 insertions(+) create mode 100644 tests/conformance/ch07_invisible_import_op_name.vera create mode 100644 tests/conformance/ch07_invisible_import_op_name_lib.vera create mode 100644 tests/conformance/ch08_module_generic_diamond.vera create mode 100644 tests/conformance/ch08_module_generic_diamond_base.vera create mode 100644 tests/conformance/ch08_module_generic_diamond_mid1.vera create mode 100644 tests/conformance/ch08_module_generic_diamond_mid2.vera create mode 100644 tests/conformance/ch09_invisible_import_ability_op.vera create mode 100644 tests/conformance/ch09_invisible_import_ability_op_lib.vera create mode 100644 tests/conformance/ch09_nested_helper_family_op_name.vera create mode 100644 tests/conformance/ch09_nested_helper_family_op_name_lib.vera diff --git a/tests/conformance/ch07_invisible_import_op_name.vera b/tests/conformance/ch07_invisible_import_op_name.vera new file mode 100644 index 00000000..6681c9a2 --- /dev/null +++ b/tests/conformance/ch07_invisible_import_op_name.vera @@ -0,0 +1,60 @@ +-- Conformance: a bare effect op beside an invisible same-named import +-- (Chapter 7, #1299) +-- Tests: spec §7.4 resolves a bare `get(())` against the CALL SITE's scope — +-- declarations first, then the effect row. The imported module's `get` is +-- private, so it is in no scope here and the call is the `State` +-- operation, whatever the flat WASM namespace happens to contain. Codegen +-- used to read a flat mirror of every absorbed symbol and lower this call as a +-- call to that invisible declaration: 7007 where the cell holds 42007. +-- The module's own `touch` is the control — its `get` IS in its scope, so it +-- keeps reaching it. +-- Two consumers read that scope, so the call appears in both positions. In +-- value position the WASM dispatch types it; as a GENERIC'S ARGUMENT +-- instantiation discovery types it too, to name the clone — and discovery's +-- table is program-wide, so it named `idg$Nat` from the invisible +-- declaration's return where the cell says `idg$Int`, and the negative cell +-- value reached a clone of the wrong signedness. +-- 42007 + (0 - 5) + 7007 == 49009. +import ch07_invisible_import_op_name_lib(touch); + +private forall fn idg(@T -> @T) + requires(true) + ensures(true) + effects(pure) +{ + @T.0 +} + +public fn cell_value(@Unit -> @Int) + requires(true) + ensures(@Int.result == 42007) + effects(pure) +{ + handle[State](@Int = 42007) { + get(@Unit) -> { resume(@Int.0) }, + put(@Int) -> { resume(()) } + } in { + get(()) + } +} + +public fn wrapped_cell_value(@Unit -> @Int) + requires(true) + ensures(@Int.result == 0 - 5) + effects(pure) +{ + handle[State](@Int = 0 - 5) { + get(@Unit) -> { resume(@Int.0) }, + put(@Int) -> { resume(()) } + } in { + idg(get(())) + } +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(@Int.result == 49009) + effects(pure) +{ + cell_value(()) + wrapped_cell_value(()) + touch(()) +} diff --git a/tests/conformance/ch07_invisible_import_op_name_lib.vera b/tests/conformance/ch07_invisible_import_op_name_lib.vera new file mode 100644 index 00000000..6057756b --- /dev/null +++ b/tests/conformance/ch07_invisible_import_op_name_lib.vera @@ -0,0 +1,25 @@ +-- Conformance: a module whose private helper is named like an effect op +-- (Chapter 7, #1299) +-- Tests: `get` here is PRIVATE, so no importer can name it — but it is +-- reachable from this module's own body, so it is compiled into the importer's +-- flat WASM module all the same. Its own caller must still reach it. +module ch07_invisible_import_op_name_lib; + +-- `@Nat`, so a clone named from THIS declaration's return type differs from +-- one named from an `Int` cell — and shares its machine width, so the +-- divergence lands as a wrong-signedness answer rather than a load failure. +private fn get(@Unit -> @Nat) + requires(true) + ensures(@Nat.result == 7007) + effects(pure) +{ + 7007 +} + +public fn touch(@Unit -> @Nat) + requires(true) + ensures(@Nat.result == 7007) + effects(pure) +{ + get(()) +} diff --git a/tests/conformance/ch08_module_generic_diamond.vera b/tests/conformance/ch08_module_generic_diamond.vera new file mode 100644 index 00000000..ae780c5d --- /dev/null +++ b/tests/conformance/ch08_module_generic_diamond.vera @@ -0,0 +1,18 @@ +-- Conformance: two modules' same-named generics in one program (Chapter 8, #1281) +-- Tests: a diamond where `mid1` declares a private `forall fn gen` and +-- `mid2` bare-calls the public `gen` of the shared `base` both import. After +-- #1274 the two are in different clone namespaces (`mod$…$mid1$gen$Bool` and +-- `mod$…$base$gen$Bool`) and nothing is emitted under a generic's bare name at +-- all, so the flat-namespace overwrite [E608] guards cannot occur — the rail +-- now reads that ownership classification instead of the bare name alone. +-- Each door must answer its OWN module's generic: 555 + 111 == 666. +import ch08_module_generic_diamond_mid1(door1); +import ch08_module_generic_diamond_mid2(door2); + +public fn main(@Unit -> @Int) + requires(true) + ensures(@Int.result == 666) + effects(pure) +{ + door1(true) + door2(true) +} diff --git a/tests/conformance/ch08_module_generic_diamond_base.vera b/tests/conformance/ch08_module_generic_diamond_base.vera new file mode 100644 index 00000000..5b0fcbd2 --- /dev/null +++ b/tests/conformance/ch08_module_generic_diamond_base.vera @@ -0,0 +1,13 @@ +-- Conformance: shared base of the same-named-generic diamond (Chapter 8, #1281) +-- Tests: a public generic two sibling modules both reach. From the entry +-- program it is reached only TRANSITIVELY, so it owns no bare name there and +-- its clones live under `mod$…$gen$Bool`. +module ch08_module_generic_diamond_base; + +public forall fn gen(@T -> @Int) + requires(true) + ensures(@Int.result == 111) + effects(pure) +{ + 111 +} diff --git a/tests/conformance/ch08_module_generic_diamond_mid1.vera b/tests/conformance/ch08_module_generic_diamond_mid1.vera new file mode 100644 index 00000000..71eac3ac --- /dev/null +++ b/tests/conformance/ch08_module_generic_diamond_mid1.vera @@ -0,0 +1,23 @@ +-- Conformance: private same-named generic beside an imported one (Chapter 8, #1281) +-- Tests: this module declares its OWN `gen` and also imports one, so §8.5.2 +-- gives its bare call to the local declaration. Private, so the entry program +-- can never name it: qualified-only, `mod$…$mid1$gen$Bool`. +module ch08_module_generic_diamond_mid1; + +import ch08_module_generic_diamond_base; + +private forall fn gen(@T -> @Int) + requires(true) + ensures(@Int.result == 555) + effects(pure) +{ + 555 +} + +public fn door1(@Bool -> @Int) + requires(true) + ensures(@Int.result == 555) + effects(pure) +{ + gen(@Bool.0) +} diff --git a/tests/conformance/ch08_module_generic_diamond_mid2.vera b/tests/conformance/ch08_module_generic_diamond_mid2.vera new file mode 100644 index 00000000..e57e504e --- /dev/null +++ b/tests/conformance/ch08_module_generic_diamond_mid2.vera @@ -0,0 +1,15 @@ +-- Conformance: bare call to a dependency's generic (Chapter 8, #1281) +-- Tests: this module declares no generic at all, so its bare `gen` is the one +-- it imports. Its sibling declares a `gen` of its own; before #1281 the pair +-- was refused outright with [E608]. +module ch08_module_generic_diamond_mid2; + +import ch08_module_generic_diamond_base; + +public fn door2(@Bool -> @Int) + requires(true) + ensures(@Int.result == 111) + effects(pure) +{ + gen(@Bool.0) +} diff --git a/tests/conformance/ch09_invisible_import_ability_op.vera b/tests/conformance/ch09_invisible_import_ability_op.vera new file mode 100644 index 00000000..8630d23e --- /dev/null +++ b/tests/conformance/ch09_invisible_import_ability_op.vera @@ -0,0 +1,28 @@ +-- Conformance: the ability operation `show` beside an invisible same-named +-- import (Chapter 9, #1299) +-- Tests: the INTRINSIC gate of `_translate_call`, which reads the same +-- bare-call ownership table the effect-op dispatch does. `show(42)` here is +-- the ability operation (spec §9.8) — the imported module's `show` is private, +-- so it is in no scope at this call site — and its result is the String "42", +-- whose length is 2. Codegen used to read a flat mirror of every absorbed +-- symbol, skip the ability dispatch, and lower a call to the module's @Int +-- function instead; `string_length` then received an i64 and the module failed +-- to load. The module's own `touch` is the control in the same program: its +-- `show` IS in its scope, so it keeps reaching it. 2 + 7007 == 7009. +import ch09_invisible_import_ability_op_lib(touch); + +public fn shown_length(@Unit -> @Int) + requires(true) + ensures(@Int.result == 2) + effects(pure) +{ + string_length(show(42)) +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(@Int.result == 7009) + effects(pure) +{ + shown_length(()) + touch(()) +} diff --git a/tests/conformance/ch09_invisible_import_ability_op_lib.vera b/tests/conformance/ch09_invisible_import_ability_op_lib.vera new file mode 100644 index 00000000..610cf5fb --- /dev/null +++ b/tests/conformance/ch09_invisible_import_ability_op_lib.vera @@ -0,0 +1,23 @@ +-- Conformance: a module whose private helper is named after an ability op +-- (Chapter 9, #1299) +-- Tests: `show` and `hash` are the ability operations E151 does NOT reserve +-- (#908), so a module may declare a function of that name where it may not +-- declare `array_length`. This one is private, so no importer can name it — +-- but its own caller must still reach it. +module ch09_invisible_import_ability_op_lib; + +private fn show(@Int -> @Int) + requires(true) + ensures(@Int.result == 7007) + effects(pure) +{ + 7007 +} + +public fn touch(@Unit -> @Int) + requires(true) + ensures(@Int.result == 7007) + effects(pure) +{ + show(1) +} diff --git a/tests/conformance/ch09_nested_helper_family_op_name.vera b/tests/conformance/ch09_nested_helper_family_op_name.vera new file mode 100644 index 00000000..c2b64560 --- /dev/null +++ b/tests/conformance/ch09_nested_helper_family_op_name.vera @@ -0,0 +1,51 @@ +-- Conformance: a generic's `where` FAMILY resolving a bare op name +-- (Chapter 9, #1299) +-- Tests: `collect_generic_helper_instances`, the discovery leaf both codegen +-- and the verifier drive directly, when a sibling helper is called with the +-- result of a bare `get(())`. Spec §7.4 resolves that call against the call +-- site's scope: the imported module's `get` is private, so the call is the +-- `State` operation and `gsib` instantiates at `Int`. +-- Read from the invisible declaration's `@Bool` return instead, discovery +-- names `gsib` while the WASM rewrite names `gsib$Int` — nothing +-- emitted matches, `ginner`'s clone is skipped [E602] and `main` is dropped +-- from a program `check` and `verify` both pass. Silent in the worst way: +-- eight obligations verified against a module with no `main` in it. +import ch09_nested_helper_family_op_name_lib(touch); + +private forall fn outer(@T -> @Int) + requires(true) + ensures(@Int.result == 7) + effects(pure) +{ + ginner(@T.0) +} +where { + forall fn ginner(@U -> @Int) + requires(true) + ensures(@Int.result == 7) + effects(pure) + { + handle[State](@Int = 42007) { + get(@Unit) -> { resume(@Int.0) }, + put(@Int) -> { resume(()) } + } in { + gsib(get(())) + } + } + + forall fn gsib(@V -> @Int) + requires(true) + ensures(@Int.result == 7) + effects(pure) + { + 7 + } +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(@Int.result == 7) + effects(pure) +{ + outer(1) +} diff --git a/tests/conformance/ch09_nested_helper_family_op_name_lib.vera b/tests/conformance/ch09_nested_helper_family_op_name_lib.vera new file mode 100644 index 00000000..aef9b8f4 --- /dev/null +++ b/tests/conformance/ch09_nested_helper_family_op_name_lib.vera @@ -0,0 +1,24 @@ +-- Conformance: module with a private helper named like an effect op, for the +-- nested-helper-family shape (Chapter 9, #1299) +-- Tests: the same invisible-declaration premise as +-- ch07_invisible_import_op_name_lib, kept separate because the importer here +-- exercises the discovery LEAF under a generic's `where` family rather than +-- the dispatch gates. `@Bool` so a clone named from this declaration's +-- return type differs from one named from the importer's `State` cell. +module ch09_nested_helper_family_op_name_lib; + +private fn get(@Unit -> @Bool) + requires(true) + ensures(@Bool.result == true) + effects(pure) +{ + true +} + +public fn touch(@Unit -> @Bool) + requires(true) + ensures(@Bool.result == true) + effects(pure) +{ + get(()) +} diff --git a/tests/conformance/manifest.json b/tests/conformance/manifest.json index 39b5ad20..bd14eb46 100644 --- a/tests/conformance/manifest.json +++ b/tests/conformance/manifest.json @@ -2833,6 +2833,62 @@ "check_error" ] }, + { + "id": "ch09_invisible_import_ability_op_lib", + "file": "ch09_invisible_import_ability_op_lib.vera", + "chapter": 9, + "title": "Module with a private helper named after an ability op", + "level": "verify", + "spec_ref": "Section 9.8", + "features": [ + "cross_module", + "visibility", + "ability_operation" + ] + }, + { + "id": "ch09_invisible_import_ability_op", + "file": "ch09_invisible_import_ability_op.vera", + "chapter": 9, + "title": "Ability op beside an invisible same-named import", + "level": "run", + "spec_ref": "Section 9.8", + "features": [ + "cross_module", + "visibility", + "ability_operation", + "bare_call" + ] + }, + { + "id": "ch09_nested_helper_family_op_name_lib", + "file": "ch09_nested_helper_family_op_name_lib.vera", + "chapter": 9, + "title": "Module with a private op-named helper, for the family shape", + "level": "verify", + "spec_ref": "Section 7.4", + "features": [ + "cross_module", + "visibility", + "effect_operation" + ] + }, + { + "id": "ch09_nested_helper_family_op_name", + "file": "ch09_nested_helper_family_op_name.vera", + "chapter": 9, + "title": "Generic where-family resolving a bare op name", + "level": "run", + "spec_ref": "Section 7.4", + "features": [ + "cross_module", + "visibility", + "effect_operation", + "nested_generic", + "monomorphization", + "bare_call" + ] + }, { "id": "ch02_alias_cycle_rejected", "file": "ch02_alias_cycle_rejected.vera", @@ -3056,6 +3112,34 @@ "cell_family" ] }, + { + "id": "ch07_invisible_import_op_name_lib", + "file": "ch07_invisible_import_op_name_lib.vera", + "chapter": 7, + "title": "Module with a private helper named like an effect op", + "level": "verify", + "spec_ref": "Section 7.4", + "features": [ + "cross_module", + "visibility", + "effect_operation" + ] + }, + { + "id": "ch07_invisible_import_op_name", + "file": "ch07_invisible_import_op_name.vera", + "chapter": 7, + "title": "Bare effect op beside an invisible same-named import", + "level": "run", + "spec_ref": "Section 7.4", + "features": [ + "cross_module", + "visibility", + "effect_operation", + "state_effect", + "bare_call" + ] + }, { "id": "ch08_state_alias_per_module_lib", "file": "ch08_state_alias_per_module_lib.vera", @@ -3111,5 +3195,59 @@ "state_effect", "cell_family" ] + }, + { + "id": "ch08_module_generic_diamond_base", + "file": "ch08_module_generic_diamond_base.vera", + "chapter": 8, + "title": "Shared base of the same-named-generic diamond", + "level": "check", + "spec_ref": "Section 11.16", + "features": [ + "cross_module_generic", + "generic_function" + ] + }, + { + "id": "ch08_module_generic_diamond_mid1", + "file": "ch08_module_generic_diamond_mid1.vera", + "chapter": 8, + "title": "Private generic shadowing an imported same-named one", + "level": "verify", + "spec_ref": "Section 8.5.2", + "features": [ + "cross_module_generic", + "generic_function", + "visibility", + "shadowing" + ] + }, + { + "id": "ch08_module_generic_diamond_mid2", + "file": "ch08_module_generic_diamond_mid2.vera", + "chapter": 8, + "title": "Bare call to a dependency's public generic", + "level": "verify", + "spec_ref": "Section 11.16", + "features": [ + "cross_module_generic", + "generic_function", + "bare_call" + ] + }, + { + "id": "ch08_module_generic_diamond", + "file": "ch08_module_generic_diamond.vera", + "chapter": 8, + "title": "Two modules' same-named generics in one program", + "level": "run", + "spec_ref": "Section 11.16", + "features": [ + "cross_module_generic", + "monomorphization", + "visibility", + "transitive_import", + "bare_call" + ] } ] From 0addc74ef09f6a2b275d1069f7a6d3f855b2e64f Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 08:41:49 +0100 Subject: [PATCH 21/69] Retire two bug rows, and record the rule nobody has decided MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CHANGELOG entries for both fixes; the #1284 bullet's closing caveat now points at the fix below it rather than at an open issue. KNOWN_ISSUES loses the #1299 and #1281 rows. ROADMAP gains #1304 under modules and ecosystem: spec §8.5 defines no order between two imports that both supply a bare name, and the checker, the verifier and codegen's reroute map each pick one from their own injection order. Measured, the three currently agree on the last import — so E608's ambiguity gate is a conservatism about an undecided rule, not a fix for a live wrong answer, and both the predicate and its test cell say exactly that rather than overstating it. Counts are oracle-derived: 222 conformance programs, 270 corpus programs, 10,654 tests across 166 files, with the two new test-file rows and the conformance level table's three moves. Co-Authored-By: Claude --- AGENTS.md | 8 ++++---- CHANGELOG.md | 10 +++++++++- CLAUDE.md | 8 ++++---- FAQ.md | 4 ++-- KNOWN_ISSUES.md | 4 ++-- README.md | 2 +- ROADMAP.md | 4 ++-- SKILL.md | 2 +- TESTING.md | 40 +++++++++++++++++++++------------------- docs/SKILL.md | 2 +- docs/index.html | 2 +- docs/index.md | 2 +- docs/llms-full.txt | 14 +++++++------- docs/llms.txt | 2 +- vera/README.md | 6 +++--- 15 files changed, 60 insertions(+), 50 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 20640e17..c6f3d97c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Read `SKILL.md` for the full language reference. It covers syntax, slot referenc ### Conformance programs as reference -The conformance suite in `tests/conformance/` contains 216 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-two negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) instead must *fail* `check` with the E-code in their `expected_error` field. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. +The conformance suite in `tests/conformance/` contains 226 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-two negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) instead must *fail* `check` with the E-code in their `expected_error` field. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. ### Workflow @@ -187,9 +187,9 @@ Each stage is a module with a single public API function (`parse_file`, `transfo pytest tests/ -v # Run all tests (see TESTING.md) pytest tests/test_conformance.py -v # Conformance suite only mypy vera/ # Type-check the compiler -python scripts/check_conformance.py # All 216 conformance programs hold (positives pass; negatives fail with their E-code) +python scripts/check_conformance.py # All 226 conformance programs hold (positives pass; negatives fail with their E-code) python scripts/check_examples.py # All 42 examples must pass -python scripts/check_corpus_canonical.py # All 264 corpus programs in canonical form +python scripts/check_corpus_canonical.py # All 274 corpus programs in canonical form ``` Test helpers follow a pattern: `_check_ok(source)` / `_check_err(source, match)` / `_verify_ok(source)` / `_verify_err(source, match)`. See existing tests for examples. @@ -198,7 +198,7 @@ When implementing a new language feature, write the conformance program *first* ### Invariants -- All 216 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) must *fail* `check` with their `expected_error` E-code +- All 226 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) must *fail* `check` with their `expected_error` E-code - All 42 examples in `examples/` must pass `vera check` and `vera verify` - `mypy vera/` must be clean - `pytest tests/ -v` must pass diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dfd79ed..819d4ca8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed -- **A user-defined `fn get` / `fn put` is no longer hijacked by an enclosing handler** ([#1284](https://github.com/aallan/vera/issues/1284)). Three sites answered "does this `get` mean the user's declaration or the effect operation?" independently. The checker answers user-fn-first — `_check_call_with_args` looks a bare name up as a function before it looks it up as an operation, so a declaration named `get` owns every bare `get(...)` in its scope, which an arity or argument-type error at such a call site proves by reporting the *user's* signature (E201/E202). Codegen answered twice more: the declared-effect row in `vera/codegen/functions.py` withheld the intrinsic when `_fn_sigs` already owned the name, and the handler expression in `vera/wasm/calls_handlers.py` installed `get`/`put` unconditionally. From `vera check`-green source that produced, depending on the nesting shape, a **silently wrong value** (`nat_to_int(get(3))` under `handle[State](@Int = 5)` returned the cell's 5 for the function's 4, and the argument was not even emitted), a **module WASM validation rejects** (a `@Bool`-returning user `get` took `state_get_Int`'s i64 into an `i32` position; different-family nesting took the *enclosing* cell's getter at the wrong width), or a **spurious `[E602]`** in which the #1233 unaddressable-cell gate refused `main` outright, naming "a bare or qualified State operation `get`" the program never contained. The repair is one predicate, `vera.slots.bare_call_denotes_user_fn`, stating the checker's rule once and consumed by the bare-call dispatch in `vera/wasm/calls.py` (which now gates the clause-inline registry, the host-cell intrinsics and the addressability gate together), by the three bare-`FnCall` result-type inference sites, and by the monomorphizer's discovery walk — each passing its own name table, so the sites cannot answer differently about the table they share. Gating the *dispatch* rather than the *registries* is what makes it correct rather than merely consistent: the registries record which cell an op name reaches, which is true whatever the program's declarations are called, and withholding an entry answered both questions with one table. That is why the gate-only fix measured during PR #1283's review turned the loud skip into a differently-broken module, and why it also cost the qualified spelling its cell — `State.put(5)` in a function that also declares `fn put` compiled to `call $vera.put` and failed to link, which now lowers to the intrinsic the checker always meant. Discovery's `MonoContext.fn_names` moves to the same lookup-time question, so a `get(())` fixing a generic's type variable under a handler names the clone the rewrite emits. All 256 conformance and example programs emit byte-identical WAT. **The `W002` async-commutativity warning was the same defect in the checker's own file** and is corrected with them: `_collect_expr_effects` asked `lookup_effect_op` before the scoped function lookup — the last op-first consumer — so a user function named after an operation contributed the *operation's* parent effect to the commutativity analysis instead of its own declared row, wrong in both directions. A **pure** `fn get`, in a program containing no `State` at all, drew `async argument performs State effects`; a `fn get` that performs `IO`, under a row naming `Http` first, drew **no warning**, because the walk bound the name to `Http.get`, which is inside the commutative whitelist, and silently withheld the eager-evaluation warning the program is owed. Both are pinned with rename controls — the byte-identical program with the helper called `gett` / `fetch` was correct throughout — beside a control that an unshadowed bare `get(())` under a `State` row still warns, so the fix cannot degenerate into never reporting `State`. The walk's comment claiming it resolves "like the call checker above" is now true. One caveat the predicate does not close: codegen's name table is not scope-accurate, so a name the call site cannot see still answers "user-owned" there — an invisible import by either of two routes, or a `where` helper of a `forall` parent, which unlike a non-generic parent's keeps a bare key. From check-green source that is a silent wrong value, a load failure, or an E-code-less `unknown func` depending on the route, tracked as [#1299](https://github.com/aallan/vera/issues/1299); a property of the table rather than of the rule. +- **A user-defined `fn get` / `fn put` is no longer hijacked by an enclosing handler** ([#1284](https://github.com/aallan/vera/issues/1284)). Three sites answered "does this `get` mean the user's declaration or the effect operation?" independently. The checker answers user-fn-first — `_check_call_with_args` looks a bare name up as a function before it looks it up as an operation, so a declaration named `get` owns every bare `get(...)` in its scope, which an arity or argument-type error at such a call site proves by reporting the *user's* signature (E201/E202). Codegen answered twice more: the declared-effect row in `vera/codegen/functions.py` withheld the intrinsic when `_fn_sigs` already owned the name, and the handler expression in `vera/wasm/calls_handlers.py` installed `get`/`put` unconditionally. From `vera check`-green source that produced, depending on the nesting shape, a **silently wrong value** (`nat_to_int(get(3))` under `handle[State](@Int = 5)` returned the cell's 5 for the function's 4, and the argument was not even emitted), a **module WASM validation rejects** (a `@Bool`-returning user `get` took `state_get_Int`'s i64 into an `i32` position; different-family nesting took the *enclosing* cell's getter at the wrong width), or a **spurious `[E602]`** in which the #1233 unaddressable-cell gate refused `main` outright, naming "a bare or qualified State operation `get`" the program never contained. The repair is one predicate, `vera.slots.bare_call_denotes_user_fn`, stating the checker's rule once and consumed by the bare-call dispatch in `vera/wasm/calls.py` (which now gates the clause-inline registry, the host-cell intrinsics and the addressability gate together), by the three bare-`FnCall` result-type inference sites, and by the monomorphizer's discovery walk — each passing its own name table, so the sites cannot answer differently about the table they share. Gating the *dispatch* rather than the *registries* is what makes it correct rather than merely consistent: the registries record which cell an op name reaches, which is true whatever the program's declarations are called, and withholding an entry answered both questions with one table. That is why the gate-only fix measured during PR #1283's review turned the loud skip into a differently-broken module, and why it also cost the qualified spelling its cell — `State.put(5)` in a function that also declares `fn put` compiled to `call $vera.put` and failed to link, which now lowers to the intrinsic the checker always meant. Discovery's `MonoContext.fn_names` moves to the same lookup-time question, so a `get(())` fixing a generic's type variable under a handler names the clone the rewrite emits. All 256 conformance and example programs emit byte-identical WAT. **The `W002` async-commutativity warning was the same defect in the checker's own file** and is corrected with them: `_collect_expr_effects` asked `lookup_effect_op` before the scoped function lookup — the last op-first consumer — so a user function named after an operation contributed the *operation's* parent effect to the commutativity analysis instead of its own declared row, wrong in both directions. A **pure** `fn get`, in a program containing no `State` at all, drew `async argument performs State effects`; a `fn get` that performs `IO`, under a row naming `Http` first, drew **no warning**, because the walk bound the name to `Http.get`, which is inside the commutative whitelist, and silently withheld the eager-evaluation warning the program is owed. Both are pinned with rename controls — the byte-identical program with the helper called `gett` / `fetch` was correct throughout — beside a control that an unshadowed bare `get(())` under a `State` row still warns, so the fix cannot degenerate into never reporting `State`. The walk's comment claiming it resolves "like the call checker above" is now true. One caveat the predicate did not close on its own: codegen's name table was not scope-accurate, so a name the call site cannot see still answered "user-owned" there — a property of the table rather than of the rule, closed by [#1299](https://github.com/aallan/vera/issues/1299) below. + +- **A bare call is lowered against the names its call site can see** ([#1299](https://github.com/aallan/vera/issues/1299)). The [#1284](https://github.com/aallan/vera/issues/1284) ownership predicate is one rule read over two tables, and only one of them was a scope. The checker's is a lexical walk; codegen passed `set(_fn_sigs.keys())` — a flat mirror of every symbol the whole compilation absorbed — so a bare `get(())` the checker had resolved to a `State` operation was lowered as a call to a declaration the body cannot name. Four source shapes reach it, all `vera check`-green and all one defect: an imported module's **private** `fn get` (invisible, but still compiled in because the module's own bodies call it), a **public** one a selective import excludes, a `where` helper of a **`forall` parent** (which keeps a bare `_fn_sigs` key beside its clone-qualified one where a non-generic parent's helper does not), and the ability operation `show`, which `E151` does not reserve and which reaches the same table through the *intrinsic* gate rather than the operation one. How it lands is a property of the widths, not of the route: where the invisible declaration and the cell share a WAT type the module loads and returns the wrong value (7007 where the cell holds 42007), where they differ it fails to load (`type mismatch: expected i64, found i32`), and the generic-`where` route is always loud — the bare key exists in the signature table while no bare *symbol* is emitted, so the call dies at WAT assembly on `unknown func: failed to find name $get` with no E-code. The repair splits the two questions the one set was answering. `_known_fns` keeps the flat registry for `_translate_call`'s guard rail, which asks whether a *resolved* target — already mono-mangled, already `mod$…` rerouted — has an implementation, and is flat by nature. A new `_scoped_fns` carries the names visible in the compiling declaration's **lexical** scope, and that is what the ownership predicate reads: its namespace's own declarations plus the public, in-filter names of the imports *that namespace* makes (spec §8.6.4 — imports are never inherited, so a transitively-reached module contributes nothing to the entry program), the prelude, and the `where` helpers of every enclosing function. Module scope alone would not have closed the third route: a generic's helper *is* in the module and still is not in a sibling's scope. The narrowing is a strict subset of the registry by construction — every `$`-bearing key is admitted unconditionally, since `$` cannot occur in a Vera identifier and a mangled name is never what a bare source call spells — so it can only withdraw a name the flat table wrongly claimed. + + Three consumers read that question, not one, and the third is reached by wrapping the same call in a generic. **Instantiation discovery** (`MonoContext.fn_names`) types a bare call to NAME the clone, and its table is program-wide by nature — the guard rail needs every symbol in it — so `idg(get(()))` beside an invisible `fn get(@Unit -> @Bool)` discovered `idg` where the checker had typed the `State` cell. Discovery now enters the namespace of the declaration it is walking (`Monomorphizer.namespace_scope`, accumulating each function's own `where` helpers as it descends, exactly as it already accumulates `forall` binders), and **both sides enter it at all ten walks between them** — six on codegen's side, four on the verifier's — from the same shared derivation, so narrowing one and not the other would leave a clone verified that nobody emits. The tenth is the one worth naming: `collect_generic_helper_instances`, the leaf under a generic's `where` family, is driven *directly* by both, so leaving it unscoped left them agreeing while both read the flat table — and two sides being wrong together is exactly what a differential cannot see. It is pinned against the **checker's** answer instead, and an instrumented audit over the corpus reports zero entries into the scoped region with no scope entered. Behind them the WASM call-rewrite's clone-naming override (`_declared_return_clone_name`, which beats the general inference for #899's benefit) read the same flat return-type registry and is gated on the same predicate. Depending on the widths, the shapes landed as a load failure (`expected i32, found i64`), a live clone of the wrong signedness reached by a negative cell, or — with discovery corrected and the override not — an `[E602]` drop of the caller. + + All 258 pre-existing conformance and example programs emit byte-identical WAT, and all three gates now have conformance coverage: reverting any one of them turns the suite red. + +- **E608 no longer refuses two modules' provably distinct generics** ([#1281](https://github.com/aallan/vera/issues/1281)). The flat-namespace collision rail exists because Pass 2.5 emits every imported function under one WASM name — but a generic emits nothing under its bare name, and since [#1274](https://github.com/aallan/vera/issues/1274) its clones live in a namespace chosen per *owner*: `gen$Bool` for a generic that owns the importer's bare name, `mod$$gen$Bool` for one that does not. A diamond where `base` declares a public `forall fn gen` and `mid1` a private one occupies two different namespaces and was refused outright with `Function 'gen' is defined in both imported module 'mid1' and 'base'`, while `vera verify` returned rc=0 on the same program — a loud verify-vs-compile disagreement. The rail now reads the same ownership classification the clone namespace does, and fires only when the pair really can collide: when either declaration is not a top-level generic (a non-generic *is* emitted under the bare `$name`), when both own the bare name, or when some namespace can name both — a module importing two dependencies that each export `gen` resolves its own bare call to one of them, and neither spec §8.5 nor the checker says which: the checker's pick is a set-iteration artefact, and the identical program accepts on one run and is rejected on the next (measured 4/4 over eight runs, stable under a fixed `PYTHONHASHSEED`, and unchanged by the textual order of the two imports), so that shape keeps its refusal rather than compiling a body chosen by coin-flip ([#1304](https://github.com/aallan/vera/issues/1304)). The registration moved with the message, as defence in depth: a qualified-only generic no longer injects a bare `_fn_sigs` or `_fn_ret_type_exprs` entry at all. Those two registries are read *per name* by consumers the clone classification says nothing about — `MonoContext.fn_names`, the [#1207](https://github.com/aallan/vera/issues/1207) shadow guard, and the WASM call-rewrite's return-type lookup — where first-module-wins would make the answer depend on registration order. What actually closes that shape is the #1299 scope narrowing above, which reaches the same consultors through the call site: reverting both withholdings leaves every suite and all 224 conformance programs green. They are kept, and pinned structurally on the tables they act on with one cell each, because nothing but those four consumers' current internals stops any of them from picking a winner. - **`new(State)` reads the cell its contract names** ([#1285](https://github.com/aallan/vera/issues/1285)). `old(State)` has been keyed on the resolved cell family since [#1205](https://github.com/aallan/vera/issues/1205)/[#1209](https://github.com/aallan/vera/issues/1209); `new(State)` read the name-keyed `_effect_ops["get"]`, which holds whichever `State` the effect row registered first. Under a single-`State` row the two keyings coincide, which is why the corpus agreed; under a multi-`State` row the two sides of one `ensures` clause read different cells. `effects(, State>)` with `ensures(new(State) == false)` was check-green *and* verify-green, emitted `state_get_Int`'s i64 into the Bool comparison's `i32.eq`, and died at load with wasmtime's raw `type mismatch: expected i32, found i64`. The type mismatch is the symptom rather than the defect: where both cells share a machine width — `State` beside `State` — the module loaded and answered about the other cell, so `ensures(new(State) == old(State))` on a function that writes neither was refuted at runtime on a contract the verifier had discharged. Codegen now carries a family→getter registry populated at the declared-row registration site from the per-family `CellNames` it already computes, and `_translate_new_expr` keys on `_state_effect_family` exactly as `_translate_old_expr` does. A bare `get(())` names no family and so is right to keep reading the name-keyed registry, source-order-first-wins; a contract names one and must not. diff --git a/CLAUDE.md b/CLAUDE.md index 69d5935e..397786cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,9 +58,9 @@ VERA_JS_COVERAGE=1 pytest tests/test_browser.py -v # Browser tests with JS cove VERA_EAGER_GC=1 vera run file.vera # Force GC on every alloc (see ENVIRONMENT.md, debug knob for #593-class GC-rooting bugs) mypy vera/ # Type-check the compiler itself -python scripts/check_conformance.py # Verify all 216 conformance programs (positives pass their level; negatives fail with their expected_error E-code) +python scripts/check_conformance.py # Verify all 226 conformance programs (positives pass their level; negatives fail with their expected_error E-code) python scripts/check_examples.py # Verify all 42 examples parse + check + verify -python scripts/check_corpus_canonical.py # Verify all 264 corpus programs are in canonical form (vera fmt) +python scripts/check_corpus_canonical.py # Verify all 274 corpus programs are in canonical form (vera fmt) python scripts/check_examples_readme.py # Verify vera run commands in examples/README.md python scripts/check_spec_examples.py # Verify spec code blocks parse python scripts/check_readme_examples.py # Verify README code blocks parse @@ -92,7 +92,7 @@ See [`TOOLCHAIN.md`](TOOLCHAIN.md) for the CLI cookbook — driving the toolchai - `vera/` — Reference compiler: grammar, parser, AST, transformer, type checker, verifier, codegen, CLI - `examples/` — 42 example Vera programs (all must pass `vera check` and `vera verify`) - `tests/` — Test suite (unit tests + conformance suite) -- `tests/conformance/` — 216 conformance programs validating every language feature against the spec +- `tests/conformance/` — 226 conformance programs validating every language feature against the spec - `scripts/` — CI and validation scripts ## Writing Vera code @@ -129,7 +129,7 @@ Before changing code — **adding or removing** — write the test that proves y ## What not to break - Pre-commit hooks run mypy + pytest + conformance suite + example validation on every commit -- All 216 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) must *fail* `check` with their `expected_error` E-code +- All 226 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) must *fail* `check` with their `expected_error` E-code - All 42 examples in `examples/` must pass `vera check` and `vera verify` - Version must stay in sync across `pyproject.toml`, `vera/__init__.py`, `docs/index.html`, `README.md`, and `uv.lock` (gated by `scripts/check_version_sync.py`); CHANGELOG.md must also carry a matching `## [X.Y.Z]` section - All tests must pass: `pytest tests/ -v` diff --git a/FAQ.md b/FAQ.md index efd1cf65..10fb7f06 100644 --- a/FAQ.md +++ b/FAQ.md @@ -236,7 +236,7 @@ None of this is Vera-specific, but it validates the design choices. The thesis i This is a real concern. LLMs are trained on trillions of tokens of Python, TypeScript, and JavaScript. A MojoBench study (NAACL 2025) found that even fine-tuned models achieved only 30–35% improvement over base models on Mojo code generation, illustrating the cold-start problem for new languages. -Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (216 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification. +Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (226 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification. ## How does Vera compare to Dafny / Lean / Koka / F*? @@ -279,7 +279,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 10,803 tests, including a 216-program conformance suite +- 10,990 tests, including a 226-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index e33dd002..faeb5ae0 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -8,19 +8,19 @@ Defects in shipped compiler, runtime, or tooling behaviour — this table matche | Bug | Issue | |-----|-------| +| A module-declared generic instantiated at a type that comes from an EFFECT-OPERATION result never registers its clone. Codegen reports a compilation note (`call target 'mod$$$' not registered`) and silently drops the calling function, so the emitted module has no `main` — from a program `vera check` and `vera verify` both pass. Two conditions, bisected and both necessary: the generic is declared in a **module** (so its clone is the `mod$`-mangled form) and its type argument is inferred from an effect operation's result. Neither nesting under a `where` helper nor `private` visibility is needed — `public` fails identically. Controls that pass: a literal type argument, a type argument from a plain module-function result, and the identical shape with the generic declared in the entry file. This is the `mod$`-mangled REGISTRATION path for module generics (the #1274/#1281 machinery), distinct from #1299's scope-table divergence — measured identical on `release/v0.1.12`'s base and on the #1299/#1281 branch, so that fix neither causes it nor changes it. | [#1310](https://github.com/aallan/vera/issues/1310) | +| The checker resolves a bare call to a SIBLING function's `where` helper. Spec §5 makes a helper local to its parent, and the checker's own `_lookup_function_scoped` implements that — it walks the enclosing frame stack and reads each frame's direct helpers — but it then falls back to `env.lookup_function`, and `vera/registration.py` has recursed every helper into that flat `TypeEnv`. So a top-level `other()` calling `helperx(7)`, where `helperx` is a helper of an unrelated `holder`, is **check-green and verify-green** and then refused by codegen (`Function 'helperx' is not defined in this module and was not found in any imported module`) — the helper is emitted as `holder$where$helperx`, so the bare call has no target. Loud, never a wrong answer. The op-name variant is the one that matters for the #1284 ownership predicate: with the helper named `get` and the sibling reading a `State` cell by bare `get(())`, the checker binds the HELPER and reports `[E202] Argument 0 of 'get' has type Unit, expected Int` where spec §7.4 resolves the operation — so the checker rejects a program codegen compiles correctly, the two tables disagreeing in the CHECKER's direction for the first time. Codegen's `_scoped_fns` (#1299) implements the spec rule; the fix here is a checker change, whose new rejections need their own blast-radius pass. | [#1307](https://github.com/aallan/vera/issues/1307) | | `json_parse` of a lone-surrogate escape substitutes U+FFFD in the browser and crashes the reference host. `json_parse("{\"k\":\"a\\ud800b\"}")` — whose Vera source is plain ASCII, so nothing refuses it earlier — prints `{"k":"a�b"}` in the browser and kills `vera run` with a `UnicodeEncodeError` traceback natively. Both hosts decode a JSON `\uD800` escape into a string holding an unpaired surrogate, which has no well-formed UTF-8 encoding, and they then part company writing it into WASM memory: `TextEncoder` replaces it with U+FFFD and returns, while `_alloc_string`'s `.encode("utf-8")` raises. Neither host is obviously right — one silently swaps a different valid string for the requested one, the other refuses but through the raw-traceback path [#1302](https://github.com/aallan/vera/issues/1302) tracks. Sibling of [#1306](https://github.com/aallan/vera/issues/1306): both are the same question about `json_parse`'s domain — which inputs it accepts, and which call refuses the ones it should not. The canonical serializers are NOT implicated: `dumps_canonical` emits a raw surrogate where the browser's walk escapes it, but the string boundary eliminates surrogates before either can see one, so the divergence is unreachable from there. Fix direction: rule on whether a Vera `String` is UTF-8 by construction — if it is, `json_parse` refuses the escape on both hosts and says so; if it is not, both hosts need one agreed lossy or WTF-8 representation. | [#1308](https://github.com/aallan/vera/issues/1308) | | `json_parse` accepts `NaN`, `Infinity` and `-Infinity` on the reference runtime and refuses them in the browser. The reference host calls `json.loads`, whose default `parse_constant` admits all three; the browser host calls `JSON.parse`, which refuses them as RFC 8259 requires. Measured through identical module bytes under both runtimes, `NaN`, `Infinity`, `-Infinity` and a nested `[NaN]` each take the `Ok` arm natively and the `Err` arm in the browser — a §12.9.3 divergence on the shared surface, and one no test covered because every JSON input in the parity battery is RFC-conformant. The hosts also disagree about *which call* refuses: the browser returns a handled `Err` from the parse, while natively the value parses and the following `json_stringify` raises the non-finite refusal [#1293](https://github.com/aallan/vera/issues/1293) installed, which [#1302](https://github.com/aallan/vera/issues/1302)'s host-callback path then presents as a Python traceback. Fix direction: hand `json.loads` a `parse_constant` that rejects, so the reference host refuses at the point RFC 8259 and the browser already do. | [#1306](https://github.com/aallan/vera/issues/1306) | | `json_keys`' result cannot be used from compiled Vera. The `Some` arm of its `Option>` binds a match binder of type `Array`, and codegen emits that binder's local as `(local $lN i32_pair)` — `i32_pair` being the internal String `(ptr, len)` spelling rather than a WASM value type — so the module fails WAT assembly with `unexpected token, expected one of: i32, i64, …`. A ten-line `public fn key_count(@Json -> @Int)` matching `json_keys` and calling `array_length` on the binder passes `vera check` and fails `vera compile`; `string_join` on the same binder fails identically. Loud rather than wrong, and narrow: `array_length(map_keys(...))` compiles, so `Array` alone is fine, and `json_get_array`'s `Some(@Array)` binder compiles, so a binder alone is fine — it is the pair that breaks. Fix direction: give the binder's local the array's own value type instead of its element's. | [#1305](https://github.com/aallan/vera/issues/1305) | | A non-finite `JNumber` reaching `json_stringify` fails with the right sentence and the wrong presentation: `vera run` emits a **61-line, 3,214-character Python traceback** — 15 stack frames, 7 of them inside `.venv/`, and **no mention of the user's `.vera` file at all** — with the actual message (`json_stringify: NaN is not representable in JSON — RFC 8259 has no NaN or Infinity. …`) as its last line. A contract violation in the same program shape produces **5 lines**: the failed clause and a source backtrace with `.vera` line numbers. The buffered stdout is discarded on the way out rather than surfaced, so the `#522` output-preservation contract is lost too. The invariant this breaks is already written down, in a comment on `host_print` in `vera/codegen/api.py`: "A user-level program must never produce a Python traceback regardless of what it does — the `WasmTrapError` contract from #516/#522/#547 holds here too." Root cause: `execute()` converts an exception into `WasmTrapError` only when `type(exc).__name__` is `Trap` or `WasmtimeError`, and a `ValueError` raised inside a host callback arrives at that handler as a `ValueError`, so the branch is skipped — no classification, no source-map frame resolution, no captured output. `json_stringify` is the case that surfaced it ([#1293](https://github.com/aallan/vera/issues/1293)), but the gap is generic: any host callback raising a non-`Trap` Python exception takes the same path. Fix direction: give host-callback failures a Vera-native shape — a dedicated error type classified alongside the traps, or widen the conversion to any exception escaping a host import — carrying the captured stdout and a source-mapped backtrace either way. | [#1302](https://github.com/aallan/vera/issues/1302) | | `md_parse` diverges between the native and browser runtimes on **211 of 1,471** adversarial inputs (14.3%) and **329 of 4,858** blank-line-separated sections of the project's own documentation (6.8%), measured at [PR #1303](https://github.com/aallan/vera/pull/1303) by comparing the two ADTs directly rather than their renders. The second denominator is the repository's own Markdown, so it moves whenever a document gains or loses a section; 11 of those 4,858 also differ in the rendered output. Nine classes, each with a one-line repro. The largest by a wide margin — 173 of the 211 — is *plain-text run grouping*: the browser emits one `MdText` per scan segment where the reference coalesces adjacent runs, so `**unclosed` is `[MdEmph([]), MdText("unclosed")]` natively and `[MdText("*"), MdText("*unclosed")]` in the browser. That class is invisible to `md_render` (the runs concatenate to the same text) but not to a Vera program that matches on the ADT, which is what makes it a §12.9.3 violation rather than a cosmetic one. The rest are render-visible: emphasis/strong scanning (`***both***` renders `**both****` natively, `***both***` in the browser); list-continuation indent width, where the reference strips exactly two (or three) characters and the browser strips all leading whitespace (`- a\n b` → `- a b` vs `- a b`); a `+` bullet, unrecognised in the browser; an `n)` ordered marker, likewise; a loose list, one list natively and two in the browser (`- a\n\n- b`); nesting past two levels, flattened in the browser (`- a\n - b\n - c`); a thematic break with internal spaces (`* * *`); and a table without a separator row (`\| a \| b \|\n- li`). Neither implementation is the specification — §9.7.3 pins the ADT, not the grammar that produces it — so closing this means choosing a parse for each class and stating it, then mirroring. Successor to [#1294](https://github.com/aallan/vera/issues/1294), which closed the `md_render` half; the parity suite pins the shapes they do agree on, so a regression on one of those goes red. | [#1301](https://github.com/aallan/vera/issues/1301) | -| Codegen's `_known_fns` mirrors the flat `_fn_sigs`, which keys a name the call site **cannot see** under its bare name, so the #1284 ownership predicate answers "user-owned" at a site where the checker resolved the effect operation — one rule, two tables that disagree. Three demonstrated routes: a `private fn get` in an imported module (still reachable, so still emitted); a `public fn get` a selective import excludes; and a `where` helper of a **`forall` parent**, which keeps a bare `_fn_sigs` key beside its clone-qualified one (`helper`/`id` in `ch09_generic_where_helper.vera`) where a non-generic parent's helper does not. All three are check-green, and how the failure lands depends on the route and on the widths: the two import routes are the dangerous ones — where both cells are i64 the module loads and returns the invisible helper's value instead of the cell's (**silent wrong value**), and where the widths differ it fails to load (`type mismatch: expected i64, found i32`). The generic-`where` route is always **loud**: the bare key exists in the signature table while no bare symbol is emitted (the helper is only ever `holder$Bool$where$get`), so the call fails at WAT assembly with `unknown func: failed to find name $get` and no E-code. The checker's answer is nailed by a type oracle rather than asserted — the invisible `get` returns `@Bool` while the caller returns `@Int` from `get(())` and checks green, which is only possible if the checker typed the call from the `State` cell — and each route carries a rename control that returns the checker's value. Pre-existing, not introduced by the #1284 fix, which made the rule one function without making either table scope-accurate. Fix direction: `_known_fns` becomes the names visible in the compiling declaration's **lexical** scope — module scope is not enough, since a generic's `where` helper *is* in the module and still is not in a sibling's scope — or the flattening stops keying invisible names bare, the `mod$…` renaming `_resolve_module_call_wasm_name` already applies to shadowed generics being the precedent. Same module-namespace table family as [#1281](https://github.com/aallan/vera/issues/1281). Spec §7.4 states the language rule ("declarations first — a property of the call site's scope alone"); this row tracks codegen's violation of it. | [#1299](https://github.com/aallan/vera/issues/1299) | | A postcondition may name a `State` the function's effect row never declares, and `vera check` accepts it: `ensures(new(State) == false)` under `effects(>)` reports OK, then `vera compile` fails with **E699** — the internal-compiler-error diagnostic whose own text says the type checker should have rejected the input, which is exactly the situation. Both forms land there for the same reason (no cell of that family exists, so `old()` finds no snapshot local and, since [#1285](https://github.com/aallan/vera/issues/1285), `new()` finds no getter). Loud and never a wrong answer, so this is diagnostic quality rather than soundness — but it is a check-green program that cannot compile, reported against the compiler instead of against the program, with a bug-report request the user should not act on. Before #1285 the `new()` side was worse than loud: the name-keyed lookup found the row's other getter and silently read the wrong cell. Fix direction: validate an `OldExpr`/`NewExpr`'s effect reference against the declared row where the checker already validates the rest of the clause, one rule for both forms; `test_a_family_the_row_does_not_declare_is_loud_on_both_sides` pins today's E699 and is the test to flip. | [#1298](https://github.com/aallan/vera/issues/1298) | | Spec §1.4 says its reserved keywords MUST NOT be used as function names, and for **seventeen of them nothing enforces it**: `then`, `else`, `data`, `type`, `module`, `import`, `public`, `private`, `requires`, `ensures`, `invariant`, `decreases`, `effect`, `with`, `in`, `where` and `pure` all declare and pass `vera check` as `public fn (@Int -> @Int)`. (`handle` is an eighteenth acceptance and is the sanctioned host-entry-point carve-out, §5.2; the other eleven names — `resume`, `exists`, `forall`, `match`, `assert`, `assume`, `fn`, `let`, `if`, `true`, `false` — are refused **E153**.) They are not even traps: `private fn with(@Int -> @Int)` and its `where` / `type` / `pure` / `requires` / `import` siblings are **callable** from the same file, so the declaration resolves and runs like any other function. The comment above `_KEYWORD_FN_NAMES` in `vera/checker/registration.py` gives the opposite as the reason those names are absent from the set — that the contextual lexer "does not admit them as a function name, so no declaration reaches this checker at all" — so the omission rests on a premise the tree refutes, and neither the parser nor the checker holds the MUST. Two resolutions, and the choice is a ruling rather than a mechanical fix: extend `_KEYWORD_FN_NAMES` so the seventeen join `resume` under E153, or narrow §1.4's MUST to the names actually reserved. The `resume` precedent leans to enforcing — `resume` is likewise not a keyword token anywhere and likewise parses, and was reserved *at the checker* precisely because the parser had no keyword to refuse it with — and E153's own rationale, that a keyword names one construct and nothing else (DESIGN principle 3), does not depend on the name being a trap. Until the ruling lands, §1.4's `handle` carve-out sentence deliberately makes no enforcement claim, so the spec does not assert a MUST it cannot back. | [#1296](https://github.com/aallan/vera/issues/1296) | | Spec Chapter 10 is a second, hand-maintained copy of `vera/grammar.lark`, and the rule-name alignment gate `scripts/check_grammar_alignment.py` compares only rule-name headers — three classes of drift pass it unseen. **Terminals, both directions**: the header pattern requires a lowercase lead, so a fabricated terminal added to §10.2 leaves the gate green, and no declared-versus-referenced audit exists in either direction. **Rule references**: restoring a removed ambiguity to a production's right-hand side is invisible, because only headers are compared. **Production bodies**: the class most grammar edits actually fall into. Two instances are live in the chapter on `main` today, both body-level and both this issue's to close: typed holes (`"?" -> hole_expr`, in `grammar.lark` since 2026-03-30) appear nowhere in `primary_expr`, and §10.2's `BLOCK_COMMENT: /\{-[\s\S]*?-\}/` is non-nesting, contradicting both §1.3 ("They nest") and the implementation, which parses `{- a {- b -} c -}` clean. Never a wrong answer from a program — the defect is that the published grammar misdescribes the one the parser has. Fix direction: extend the gate side-aware (terminal audit both ways, reference-set comparison), or fold Chapter 10 toward the DESIGN Grammar row's actual promise of a single-sourced shared grammar rather than a hand-maintained copy held honest by ever-wider cross-checks; the two live instances are fixable independently of which direction wins. | [#1290](https://github.com/aallan/vera/issues/1290) | | `.github/workflows/release.yml`'s `Tag and create GitHub Release` step fails with `HTTP 422: Validation Failed — body is too long (maximum is 125000 characters)` when the CHANGELOG section `scripts/release.py notes` extracts into `RELEASE_NOTES.md` exceeds GitHub's release-body limit. It fired on v0.1.10, whose notes extract to roughly 148,700 bytes — the #1213 burndown's 44-issue section, some 23,000 characters past the limit — and it fired at the worst point in the pipeline: **after** PyPI had accepted the immutable archives and **after** the tag was created, leaving the release half-cut with no repeatable path back. The v0.1.10 GitHub Release was completed by hand, mirroring the step exactly: the run's artifacts downloaded and hash-verified three ways, then `gh release create --verify-tag --latest` with the wheel, sdist and SHA256SUMS, and a generated body — the section's bold bullet lead-ins as a headline index plus a link to the canonical section at the tag. Rare, since it needs a release this large, but rarity is not the mitigating factor here; the landing point is. Fix direction: make the step total — before `gh release create`, regenerate oversized notes into that index form so the release always carries a body that fits, with the full notes staying in `CHANGELOG.md`, which is already the release notes of record. | [#1288](https://github.com/aallan/vera/issues/1288) | | `_stamp_decl_order` skips the prelude stamp when a main-file `type` reuses a prelude name: `type Option = Int` checks green and leaves the prelude's `Option` without `_prelude_decl_order`, so inside a module namespace it resolves at `_BUILTIN_DECL_INDEX` instead of its prelude position. Latent — the shadow wins the arbitration by namespace rules before the index is consulted, and no observably-wrong check-green program has been constructed — but the stamp is wrong and the next decl-order consumer inherits it. Fix direction: stamp the prelude declaration unconditionally; its order is a fact about the prelude, not about what the main file declares. | [#1287](https://github.com/aallan/vera/issues/1287) | | `_infer_vera_type` (monomorphization's Vera-level type namer) still reads only `then_branch` of an `IfExpr` and only `arms[0]` of a `MatchExpr` — the #1276 join fix landed in its WAT-side siblings but not here. Confirmed by inspection; latent: a wrong answer needs an instantiation discovered through a divergent-first-arm conditional, and no check-green program reaching that shape has been constructed (the checker's upstream unification constrains the same joins). The fix is the mechanical #1276 mirror — take the first arm that yields a type — landing with the failing program that proves it, per the test-first rule. | [#1286](https://github.com/aallan/vera/issues/1286) | -| E608 rejects two modules' same-named generics that the #1274 ownership classification makes provably distinct: a diamond where `base` declares a public `forall fn gen` and `mid1` a private one is refused with `Function 'gen' is defined in both imported module 'mid1' and 'base'`, though after #1274 the two occupy different clone namespaces (`gen$Bool` vs `mod$mid1$gen$Bool`) and nothing is emitted under a generic's bare name at all. Loud (a refused compile), never a wrong answer, and pre-existing. Not relaxed alongside #1274 because the rail is keyed on `_fn_sigs`, where a generic DOES take a bare entry that flows into `MonoContext.fn_names` — the table the #1207 effect-op shadow guard consults — so letting two modules' generic signatures share that key would silently pick a winner for a consumer the clone classification says nothing about. Fix direction: give the collision rail the same ownership predicate, and in the same change either keep generics out of the shared bare `_fn_sigs` injection or key their entries per owner. | [#1281](https://github.com/aallan/vera/issues/1281) | | Codegen's per-namespace ADT membership (#1253) recovers the global infrastructure by subtracting what the namespaces DECLARE from the registered layouts, and that subtraction is not namespace-scoped: when a module declares `data Json`, `Json` leaves the infrastructure set and is therefore absent from the member set of every namespace except that module's — including the entry program's, which never declared it and legitimately sees the prelude's. The Pass-0.5 built-in snapshot unioned in as a floor predates the Pass-1.2 prelude injection and so does not protect these names (the same asymmetry #1253 fixed, one layer down), and no E609/E610 rail fires on the declaration because those rails key on that same snapshot. **Inert at emission today** — `AliasEnv.data_types` changes an answer only in `naming._resolve_named`, and only for `Decimal` and the single `REMOVED_ALIASES` entry `Float`, so the whole corpus is byte-identical with and without the membership rule; this is a latent constraint on the next consumer rather than a live defect, and it is not release-gating. Fix direction: scope the subtraction per namespace, or teach Pass 0.5 which prelude ADTs the program will demand — **not** by reserving the names, which spec §8.4.1 forbids (the prelude's data types are ordinary public declarations a program may name and shadow; the reserved namespace is the `Vera` prefix alone, E154). Recorded alongside it: a program where a module declares `data Json` AND the entry uses prelude `Json` checks clean and then compiles to nothing (E602 inside the prelude's own `json_get`, an E620 cascade, no exports) — a diagnostic-quality problem, not soundness, since it refuses to emit rather than emitting something wrong, but the error points into `` instead of at the user's `data Json`. Within-namespace shadowing is unaffected. | [#1277](https://github.com/aallan/vera/issues/1277) | | A `throw` payload is statically obligated but not runtime-guarded: `throw(v)` narrows `v` into the `Exn` payload and the narrowing now carries the same obligation every other binding site does — a provably-violating value is a loud E503/E505 and an undischargeable one is a disclosed `tier3_unguarded` E504/E506/E531 — but codegen emits no predicate or sign guard on the payload (`throw` lowers straight to `throw $exn_` with the value on the stack), so an unverified `vera compile`/`run` still passes a violating value through. Same disclosed-unguarded class as the user-effect operation argument in #754 below, and the guard wants the same metadata; fix direction: emit the guard at the `throw` op-call site, reusing the #1203 `put`-store guard emission and the §2.6.5 boundary predicate lowering. | [#1268](https://github.com/aallan/vera/issues/1268) | | `ch05_closure_nat_return` (a run-level conformance program in the pre-commit + CI gate) trapped **once** in a full `check_conformance.py` run (`unreachable` in `main` — the sentinel `assert` or a GC shadow-stack guard) and has not reproduced in ~960 attempts across isolated, parallel, eager-GC, and hash-seed-swept executions; the emitted WAT is deterministic and correct. Suspected rare runtime/GC/wasmtime interaction, tracked so a future intermittent CI red resolves here instead of starting fresh. | [#996](https://github.com/aallan/vera/issues/996) | diff --git a/README.md b/README.md index 17d2fee9..c8c67ffe 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ cp /path/to/vera/SKILL.md ~/.claude/skills/vera-language/SKILL.md ## Project status -Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 10,803 tests, 95% Python code coverage, 216 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. +Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 10,990 tests, 95% Python code coverage, 226 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. The reference compiler — parser, AST, type checker, contract verifier (Z3), WASM code generator, module system, browser runtime, and runtime contract insertion — is working. The language specification is in draft across [14 chapters](spec/). diff --git a/ROADMAP.md b/ROADMAP.md index d8d72994..5594f831 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,7 +12,7 @@ Ordering derives from the design principles ([DESIGN.md](DESIGN.md)): verificati ## Where we are -10,803 tests, 216 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. +10,990 tests, 226 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. ## Stage 19 — The verification completeness sprint @@ -137,7 +137,7 @@ Beyond the staged sprints — grouped by arc, each pulled forward by its trigger **Concurrency and WASI** — [#406](https://github.com/aallan/vera/issues/406) WASI 0.3 native async (gated on wasmtime-py exposing component async), [#853](https://github.com/aallan/vera/issues/853) extend wasi-p2 beyond IO+Random (Http via `wasi:http` outgoing-handler, streaming filesystem, sockets), [#270](https://github.com/aallan/vera/issues/270) `handle[Async]` scheduling strategies, [#227](https://github.com/aallan/vera/issues/227) timeout/cancellation effects, [#228](https://github.com/aallan/vera/issues/228) WebSocket/SSE, [#770](https://github.com/aallan/vera/issues/770) non-blocking / timed stdin, [#844](https://github.com/aallan/vera/issues/844) advisory diagnostic for shape-unfusable `async` arguments. -**Modules and ecosystem** — [#187](https://github.com/aallan/vera/issues/187) module-qualified call disambiguation → [#127](https://github.com/aallan/vera/issues/127) module re-exports, [#130](https://github.com/aallan/vera/issues/130) package system and registry, [#163](https://github.com/aallan/vera/issues/163) standalone WASM runtime package, [#238](https://github.com/aallan/vera/issues/238) Component Model interop, [#56](https://github.com/aallan/vera/issues/56) incremental compilation, [#294](https://github.com/aallan/vera/issues/294) effect row variable unification, [#785](https://github.com/aallan/vera/issues/785) GitHits MCP (bookmark; trial at the next dependency-facing milestone). +**Modules and ecosystem** — [#1304](https://github.com/aallan/vera/issues/1304) settle which import supplies a bare name when two of them do (spec §8.5 defines no order, and the checker's pick is a set-iteration artefact that flips between runs of the identical program rather than an order at all; E608 refuses the shape before it can matter), [#187](https://github.com/aallan/vera/issues/187) module-qualified call disambiguation → [#127](https://github.com/aallan/vera/issues/127) module re-exports, [#130](https://github.com/aallan/vera/issues/130) package system and registry, [#163](https://github.com/aallan/vera/issues/163) standalone WASM runtime package, [#238](https://github.com/aallan/vera/issues/238) Component Model interop, [#56](https://github.com/aallan/vera/issues/56) incremental compilation, [#294](https://github.com/aallan/vera/issues/294) effect row variable unification, [#785](https://github.com/aallan/vera/issues/785) GitHits MCP (bookmark; trial at the next dependency-facing milestone). **Standard library long tail** — [#367](https://github.com/aallan/vera/issues/367) Markdown extractors, [#368](https://github.com/aallan/vera/issues/368) HTML accessors, [#507](https://github.com/aallan/vera/issues/507) ability-dispatched array operations, [#509](https://github.com/aallan/vera/issues/509) Unicode-aware string built-ins phase 2, [#1143](https://github.com/aallan/vera/issues/1143) `` effect phases 2–3 — named columns (via Map), typed rows (via JSON), and further backends. diff --git a/SKILL.md b/SKILL.md index 3e1ba061..13dd81ff 100644 --- a/SKILL.md +++ b/SKILL.md @@ -2452,7 +2452,7 @@ public fn main(@Unit -> @Unit) ## Conformance Suite -The `tests/conformance/` directory contains 216 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. +The `tests/conformance/` directory contains 226 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. Each program is organized by spec chapter (`ch01_int_literals.vera`, `ch04_match_basic.vera`, `ch07_state_handler.vera`, etc.) and the `manifest.json` file maps features to programs. When you need to see how a specific construct works, check the conformance program before reading the spec. diff --git a/TESTING.md b/TESTING.md index b2515c84..07fbffa5 100644 --- a/TESTING.md +++ b/TESTING.md @@ -6,9 +6,9 @@ This is the single source of truth for Vera's testing infrastructure, coverage d | Metric | Value | |--------|-------| -| **Tests** | 10,803 across 164 files (~155,000 lines of test code; 10,635 passed + 26 stress, 142 skipped) | +| **Tests** | 10,990 across 166 files (~155,000 lines of test code; 10,813 passed + 26 stress, 151 skipped) | | **Compiler code coverage** | 95% Python, 87% JavaScript (CI minimum: 80%) | -| **Conformance programs** | 216 programs across 9 spec chapters, validating every language feature | +| **Conformance programs** | 226 programs across 9 spec chapters, validating every language feature | | **Example programs** | 42, all validated through `vera check` + `vera verify` | | **Spec code blocks** | 189 parseable blocks from 14 spec chapters: 92 parse, 86 type-check, 85 verify (the rest carry inline `vera:skip` annotations, #538) | | **README code blocks** | 4 Vera blocks (4 validated, 0 annotated) | @@ -39,7 +39,7 @@ VERA_EAGER_GC=1 pytest tests/test_codegen_closures.py::TestClosureReturnShadowPu mypy vera/ # strict mode # Validation scripts -python scripts/check_conformance.py # conformance suite (216 programs, see manifest.json) +python scripts/check_conformance.py # conformance suite (226 programs, see manifest.json) python scripts/check_examples.py # 42 example programs python scripts/check_spec_examples.py # spec code blocks python scripts/check_readme_examples.py # README code blocks @@ -82,6 +82,8 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_import_visibility_entry_point_1244.py` | 6 | 269 | #1244 — `vera check` reports the same diagnostics whether it was given a module or a file that imports it. Registration alone says what a module DECLARES; the importer never checked its bodies, so a name a module never imported was rejected standalone (E200, §8.5.1) and accepted in silence through an importer. Written as EQUALITY between the two entry points rather than as "the importer warns", because the property is agreement — a future change making the standalone verdict lenient would satisfy a one-sided assertion and must fail here on the standalone leg. Six cases: the leaked unimported name, the honest control that imports what it uses (green before and after, so the new body check is the visibility rule rather than a blanket rejection of cross-module programs), the issue's type-error-through-importer shape (an `@Int` call bound to a `@Bool` slot: check-clean, Tier-1, failing at compile), a diamond proving each module is reported ONCE (the body check is memoised by path across nested checkers), and both entry points into an import cycle proving the memo terminates it | | `test_clone_body_declaring_module_1241_1243.py` | 5 | 344 | #1241 + #1243 — an imported generic's clone body resolves its bare calls in the DECLARING module, on both sides. The verifier's lexical lookup fell through to the importer's registry (`_declaring_module_scope` swapped the naming env, source and file but not the function registry) and codegen's clone-emission door was the one door that did not thread the module's intra-rename map, so `glib`'s `gen` called the importer's `need`. The two halves are one routing rule, and the tests are written so neither passes alone: each case asserts the `vera verify` verdict AND the `vera run` value together, so the verifier half alone (which makes verify clean while the compiled program still traps on the postcondition it just proved — the measured false Tier-1) fails the same test the codegen half alone (right value, verify still refusing) fails. Every expected value comes from the module verified and run STANDALONE, never from what the importer produces. Shapes: a private direct callee, a two-hop private chain, the type-discriminating pair (`@Int` vs `@Bool` — check-green source that emitted invalid WASM), and the unshadowed-callee control that was correct before and after, which is what pins the defect to the SHADOWED name rather than to cross-module calls in general | | `test_module_generic_namespace_1274.py` | 24 | 1,015 | #1274 — a module generic that does not own the importer's bare name is reached under `mod$$name`. Pre-fix only PRIVATE module generics were routed that way (#1000), so a PUBLIC one collided with the importer's same-named generic in the clone-name space: both files' `gen2` mangled to one `gen2$Bool`, one overwrote the other, and the module's own body ran the importer's — a **false Tier-1** (`check`/`verify` clean, the module's proved `ensures` violated at run: 999 where the declaring module answers 111). The full visibility matrix (module generic × importer generic), the import-filter dimension (out-of-filter, in-filter, wildcard), the unshadowed-out-of-filter cell that assembled to `unknown func $gen2`, and a type-discriminating shape whose two clones have different WAT result types. Each cell asserts the verify VERDICT and the runtime VALUE together in one test — a clean verify beside a violated postcondition IS the bug, so splitting them across sibling tests would let it hide — against the standalone library as oracle, and re-checks that the importer's own generic still answers its own value. The per-module both-sides differential lives in `test_monomorphize_differential.py`. Two further families joined after the adversarial round: the module→module **hop** — a module's bare call to a DIFFERENT module's qualified-only generic, which the per-module classification never rerouted, in both a LOUD spelling (a contract pins the answer, so a captured call traps) and a SILENT one (every contract admits both answers, so only the value distinguishes them) plus the two-hop shape where the entry never imports the declaring module at all; and the **shared-input** pair, which pins that the two sides compute the importer's occupied bare names identically — codegen reads them after Pass 0's helper renames, the verifier from the pre-transform AST, and a non-generic `where`-helper named `gen2` made the same imported generic bare-name-owning on one side and qualified-only on the other. The idempotence of that derivation is asserted directly across BOTH Pass-0 transforms and their composition — over a fixture carrying every helper shape it distinguishes (non-generic under non-generic, under a generic parent, under a generic helper, and a generic helper), since a fixture missing one would let a partial assertion look total — with each shape's membership pinned individually beside it, because idempotence alone would hold for a derivation that answered the same WRONG set every time. Two more families close the visibility dimension: the **transitive** one, driven through the production `ModuleResolver` (a hand-built `ResolvedModule` defaults `direct=True` and would never reach the path), asserting that a module reached only transitively has ALL its generics qualified-only — the entry's namespace does not hold them at all; and the **user-written qualified call** (`deep::gen(true)` where the importer declares its own `gen`), which must key its instantiation to the module's declaration rather than to whoever owns the bare name | +| `test_module_generic_collision_1281.py` | 19 | 717 | #1281 — E608 must not refuse two modules' PROVABLY DISTINCT generics. A generic emits nothing under its bare name, and since #1274 its clones live in a namespace chosen per OWNER, so the diamond (`base` public, `mid1` private, both named `gen`) cannot overwrite anything — and was refused outright, with `vera verify` returning rc=0 beside the refusal. Each door now answers its own module's generic (555 + 111) against the standalone oracles, and the emitted module carries `mod$…$mid1$gen$Bool` and `mod$…$base$gen$Bool` with nothing in the entry's bare clone namespace. The relaxation is gated on three conditions, each with its own cell: both declarations are top-level generics (a generic beside a non-generic keeps the refusal), at most one owns the bare name (asked of the predicate directly, since end to end the ambiguity gate catches that shape first), and no namespace can name both — a module importing two dependencies that each export `gen` resolves its own bare call to one of them, and neither spec §8.5 nor the checker says which: the checker's pick is a set-iteration artefact, and over eight runs of one unchanged program the type oracle accepted four times and was rejected four times (stable under a fixed `PYTHONHASHSEED`, varying with the seed, and identical whichever textual order the imports are written in), while codegen's reroute map is positional last-wins. Compiling the shape would pick a body by coin-flip, so it keeps its loud refusal (issue 1304). A namespace that declares its own `gen` is not ambiguous however many dependencies export one (§8.5.2). The registration half — a qualified-only generic contributing no bare `_fn_sigs` or `_fn_ret_type_exprs` entry — is pinned by two STRUCTURAL cells, one per table, and its docstring says why: with #1299's scope narrowing in place both withholdings are defence in depth, reverting them leaves every suite and all 224 conformance programs green, and they are kept only because four consumers read those tables per NAME and nothing but their current internals stops each from picking one | +| `test_lexical_fn_scope_1299.py` | 56 | 1,535 | #1299 — codegen's bare-call ownership table must be the CALL SITE's lexical scope. The #1284 predicate is one rule over two tables, and codegen's was `set(_fn_sigs)`: every symbol the whole compilation absorbed, including names the compiling body cannot see, so a bare `get(())` the checker resolved to a `State` operation was lowered as a call to some other declaration. Four routes, all check-green — an imported module's **private** `get`, a **public** one a selective import excludes, a `where` helper of a **`forall` parent** (which keeps a bare key beside its clone-qualified one where a non-generic parent's does not), and the ability operation `show`, which E151 does not reserve and which reaches the same table through the INTRINSIC gate rather than the op one. Where the widths agreed the module loaded and answered the invisible declaration's value (7007 for the cell's 42007); where they differed it failed to load; the generic-`where` route is always loud (`unknown func $get`). Every expected value is the checker's, PROVEN by a type oracle rather than assumed — the invisible `get` returns `@Bool` while the caller returns `@Int` from it and checks green — and each route carries a rename control. The visibility matrix (public/private × in-filter/excluded/wildcard × shadowed/unshadowed × direct/transitive) asserts the verify verdict and the runtime value together per cell. The two directions are pinned at once: the sibling loses the name, the generic TEMPLATE keeps it (asserted on the emitted instruction stream, since monomorphization supersedes the template and a value assertion would be green either way), and a lifted closure — compiled through its own `WasmContext` — inherits its parent's scope. Four table invariants sit beside them: the scoped set is a subset of the registry, every `$`-bearing key stays in it, prelude names stay in it, and every emission door supplies a declaration its own helpers | | `test_phantom_generic_instances_1271.py` | 14 | 358 | #1271 — discovery inside a still-generic scope must not instantiate a callee at an ENCLOSING scope's type VARIABLE. `pick(@U.1, @U.0)` inside `forall fn helper` bound `pick`'s variable to the NAME `U`, so a `pick$U` clone was emitted whose parameter has no WASM type and which the compilability pass then skipped with a loud `[E604]` — the noise that kept #1223's shapes out of the conformance suite. Drives the four #1223 fixtures plus a mutual-recursion shape (two sibling generic helpers under a generic parent, whose phantoms include one arriving through a callee's declared RETURN type, `leaf$W`), asserting on ONE compile that no clone is keyed by a type variable, that no E602/E604/E605 skip is emitted, AND that the genuinely concrete clone is still there — the third assertion being what separates the filter from an over-filter that would take the real instantiation with it. Plus the **primitive-spelled binder** matrix (`forall`, ``, ``, ``), each row instantiating a sibling at exactly the type its binder is spelled like — a shared `idw(5)` would have let every row but `Int` pass for free — asserted on the clone set AND on the program still running; with the `Q`-binder control that keeps a genuine type variable filtered, so the fix cannot degenerate into "never filter". That control CREATES a live phantom candidate — a generic helper under a generic parent, handing its callee an argument typed by its own binder — because a control that merely fails to create one holds under any filter including none; mutation-checked by disabling the filter, which turns it red | | `test_handle_exn_divergent_result_1276.py` | 10 | 391 | #1276 — a `handle[Exn]` whose clause body AND handled body both diverge emitted a result-LESS `block` into a result-expecting context: check-green, verify-green, rejected at load with `type mismatch: expected i64 but nothing on stack`. Four divergent shapes (the issue's Int rethrow, the `Byte` payload spelling #1269 unmasked, a three-deep rethrow chain, and a clause diverging through both arms of an `if`), each asserted on valid WASM AND on the observable — the OUTER handler's clause value, 1000. Paired with the Unit TWIN, which infers `None` for the same reason but DOES complete: it must keep running and its WAT must contain no `unreachable` at all. The pairing is the point — `result_wt is None` means two things wanting opposite lowerings, and a fix that terminated both would trap a program that runs. The MIRROR family covers a clause that throws on one path and COMPLETES on the other (`if` and `match` spellings), where the inference read only the `then` branch / arm 0, answered `None`, and left the completing path's value stranded in a result-less block; the `if` case appears twice with different thrown values so both the throwing and the completing path are exercised from one inference | | `test_generic_under_generic_callees_1223.py` | 8 | 298 | #1223 — a generic `where`-helper under a GENERIC parent instantiates its own generic callees. The helper is monomorphized only during clone hoisting, outside the worklist that rescans every clone it emits, so a top-level generic called from the helper body was discovered only in its still-generic spelling (`pick$U`, binding the enclosing type variable's NAME) while the rewrite called `pick$Bool` — E602 skip, E620 drop of the parent and of `main`, "No exported functions" from a check-clean, verify-clean program. Four shapes — a user generic, the prelude twin (`option_unwrap_or`), two levels of generic nesting where the INNER helper is the caller, and the non-generic-parent control that compiled before the fix and must keep compiling (it is what proves the trigger is the generic ancestor rather than the nested helper) — each asserted on no E602/E620, the checker's run value (the helper's argument order is non-commutative, so a miswiring gives 7 instead of 3), and a REGISTERED-vs-RESOLVED differential. That differential captures the emitted mono-decl names rather than `_emitted_instances` (whose generic-under-generic entries are keyed by the concrete-free lexical chain, not by the per-clone emission name the rewrite calls) and captures the rewrite side on `_resolve_generic_call` rather than from the WAT, because a desync skips the calling function and removes the dangling `call` along with it. The verifier's half of the pair is pinned in `test_monomorphize_differential.py`'s inline corpus, not here | @@ -95,7 +97,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_checker_errors.py` | 73 | 1,196 | Error codes, resolution-coverage diagnostics, contracts, error accumulation (#420 split); cyclic type aliases incl. #1059 self-reference through a type argument (`Future`, mutual `Future`/`Future`, `Array`) rejected E132 | | `test_checker_builtins_collections.py` | 97 | 848 | Map / Set / Decimal / Json / Html / Http / Inference built-in type-checking (#420 split) | | `test_checker_builtins_strings.py` | 122 | 945 | String / numeric / type-conversion / float-predicate / string-search / markdown / regex built-in type-checking, removed-legacy-name regression (#420 split) | -| `test_obligations.py` | 716 | 1,741 | Reified proof obligations + warm `VerificationSession` (#222 Phase A): full-corpus differential oracle (warm session == cold `verify()` on diagnostics, summary, and obligation stream, plus warm-twice determinism, across all 42 examples and every verify/run-level conformance program), summary↔obligation tier-bookkeeping consistency (including the #967 `total == tier1_verified + tier3_runtime` leg, plus a focused self-consistency pin on the three call-demotion examples), the #1242 stream partition — over a corpus widened to every conformance program that type-checks, at any level, `len(obligations) == total + violated + tier3_unguarded` and every status is one of the documented five, with the vocabulary read from the `ObligationStatus` Literal so a sixth member fails rather than vanishing from the counts — per-kind unit tests (requires / ensures / decreases / nat_sub / call_pre statuses, counterexamples, error codes), content-key stability + same-text-two-sites span disambiguation, session solver reuse, type-error short-circuit, ADT-registry resync between programs; plus the Phase B incremental suite — identical-source full replay, callee-body-edit replays callers while callee-contract-edit invalidates them, span-shift and ADT-edit conservative invalidation, cross-program isolation, timeout-status never cached (monkeypatched solver), FIFO eviction bound; plus the #727 dedup pin — a violating call in a let RHS records exactly one E501 diagnostic and one call_pre obligation; plus the #1208 call-site rendering pin — a PARAMETERISED callee slot substitutes into the E501 message and its fix instead of falling back to the generic wording | +| `test_obligations.py` | 744 | 1,741 | Reified proof obligations + warm `VerificationSession` (#222 Phase A): full-corpus differential oracle (warm session == cold `verify()` on diagnostics, summary, and obligation stream, plus warm-twice determinism, across all 42 examples and every verify/run-level conformance program), summary↔obligation tier-bookkeeping consistency (including the #967 `total == tier1_verified + tier3_runtime` leg, plus a focused self-consistency pin on the three call-demotion examples), the #1242 stream partition — over a corpus widened to every conformance program that type-checks, at any level, `len(obligations) == total + violated + tier3_unguarded` and every status is one of the documented five, with the vocabulary read from the `ObligationStatus` Literal so a sixth member fails rather than vanishing from the counts — per-kind unit tests (requires / ensures / decreases / nat_sub / call_pre statuses, counterexamples, error codes), content-key stability + same-text-two-sites span disambiguation, session solver reuse, type-error short-circuit, ADT-registry resync between programs; plus the Phase B incremental suite — identical-source full replay, callee-body-edit replays callers while callee-contract-edit invalidates them, span-shift and ADT-edit conservative invalidation, cross-program isolation, timeout-status never cached (monkeypatched solver), FIFO eviction bound; plus the #727 dedup pin — a violating call in a let RHS records exactly one E501 diagnostic and one call_pre obligation; plus the #1208 call-site rendering pin — a PARAMETERISED callee slot substitutes into the E501 message and its fix instead of falling back to the generic wording | | `test_verifier_contracts.py` | 95 | 898 | Z3 verification over the example corpus, trivial/ensures/if-else/let/multi-clause contracts, counterexamples, tier classification, arithmetic, verification summaries, Diverge effect, edge cases, string-length + string-predicate verification (#839 split) | | `test_verifier_nat_obligations.py` | 82 | 1,743 | **`@Nat` subtraction underflow obligation** (#520 — Path-A discharge via requires/path-conditions/path-aware Z3 refutation, pure-literal exclusion, Int-Int and Nat-Int exemptions) and **`@Nat` binding-site narrowing obligation** (#552/#747/#749 — Tier-1 `value >= 0` at let/call-arg/effect-op-arg/ctor-field/match-bind/destructure narrowing — a concrete site classifies `tier3_runtime` (codegen-guarded) while the effect-op argument and generic-instantiated constructor field classify `E504` (obligated but unguarded, #754/#757) whose rationale names its actual cause — an untranslatable value — rather than the untranslatable-or-timeout conflation #1251 removed, walker-recursion pins, `_narrows_into_nat` verifier/codegen soundness parity; PR #972 clone-instantiated side-table substitution — a `Some(@T)` bind in an `Option`-instantiated clone is no narrowing, genuine clone-path narrowings still obligated); #1201 — a builtin `Tuple` parameter's match-bound components carry their declared component facts (a valid ensures over one proves instead of falsely violating) and an `Int` component bound as `@Nat` fires one loud `E503` per component, both mutation-caught (#839 split) | | `test_verifier_primitive_ops.py` | 39 | 662 | **Primitive-operation safety obligations** (#680) — division/modulo by-zero `E526` and array-index-bounds `E527`, the in-bounds/out-of-bounds two-check with float-exemption, honest Tier-3 for opaque lengths, off-by-one and lower-bound pins, De Bruijn-correct fix hints (#839 split) | @@ -113,7 +115,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_soundness_392.py` | 36 | 584 | #392 audit batches 1–2 — verifier soundness/completeness fixes: signed div/mod truncate toward zero (#799), body `assert(P)` carries a Tier-1 obligation (#800), divisions in contract predicates carry a `div_zero` obligation (#801), and the #804 assume-half of #800's `assert` rule — a prior `assert`/`assume` discharges later obligations (including a later call's precondition) + the postcondition at Tier 1, removing false E501/E503/E500/E505 | | `test_int_overflow.py` | 6 | 143 | #798 — `@Int`/`@Nat` arithmetic-overflow obligations (part of the #392 `smt.py` soundness audit): `+`/`-`/`*` on `@Int`/`@Nat` now emit an `int_overflow` obligation (the analog of `nat_sub`/`div_zero`) rather than modelling the operands as Z3's unbounded integers, so a `ensures(@Int.result > @Int.0)` over `@Int.0 + 1` no longer proves a contract the i64/u64 runtime violates under two's-complement wraparound. Unbounded operands leave the obligation undischarged (Tier-3, runtime-guarded); operand bounds that prove the result stays in range discharge it at Tier 1 | | `test_int_overflow_codegen.py` | 62 | 718 | #798 Stage 3 — runtime overflow-trap codegen: the codegen emits a guard at *exactly* the `@Int`/`@Nat` `+`/`-`/`*` sites the verifier obligates, so `vera run`/`vera compile` programs trap on overflow instead of silently wrapping at the i64/u64 boundary. #808 wired the guard to the `vera.overflow_trap` host import, so the trap now classifies `kind="overflow"` (carrying the overflow Fix paragraph) rather than the generic `unreachable`; `TestOverflowTrapKind808` pins that, with controls proving the #520 `nat_sub` underflow and #813 `@Nat`→`@Int` widen guards still classify `unreachable` | -| `test_int_overflow_differential.py` | 241 | 398 | #798 Stage 3 verifier↔codegen classification differential (cross-component soundness rule): the codegen overflow guard must fire at exactly the sites the verifier obligates *and* classify each site's operand type (`@Int` i64 vs `@Nat` u64) identically — else a Tier-1-clean program traps spuriously or a wrapping op slips through unguarded. Over a corpus exercising all five operand combos plus the literal-left ambiguity (a naive codegen mis-classifies it as `@Nat`), asserts the verifier's per-site gated classification equals the codegen's site for site, both sides driven by the same `ast.span_key` | +| `test_int_overflow_differential.py` | 250 | 398 | #798 Stage 3 verifier↔codegen classification differential (cross-component soundness rule): the codegen overflow guard must fire at exactly the sites the verifier obligates *and* classify each site's operand type (`@Int` i64 vs `@Nat` u64) identically — else a Tier-1-clean program traps spuriously or a wrapping op slips through unguarded. Over a corpus exercising all five operand combos plus the literal-left ambiguity (a naive codegen mis-classifies it as `@Nat`), asserts the verifier's per-site gated classification equals the codegen's site for site, both sides driven by the same `ast.span_key` | | `test_nat_int_widening.py` | 36 | 602 | #813 — `@Nat -> @Int` widening coercion obligation (dual of #552 `nat_bind`, part of the #392 soundness audit): a `@Nat` in (i64.MAX, u64.MAX] reinterprets when widened (u64.MAX → -1), so a `nat_to_int_coerce` obligation that the value is `<= i64.MAX` now fires at the return position — provably-in-range → Tier-1, provably-out-of-range (`@Nat.0 >= 2**63`) → loud E530, unbounded → honest Tier-3 (runtime-guarded), with an `@Int -> @Int` control that must not fire; the unguarded generic-`@Int`-field case also has its `E531` rationale read for WHAT IT SAYS — a value bounded on neither side, not the untranslatable-or-timeout conflation #1251 removed. The #813 follow-up adds the explicit `nat_to_int` built-in and heterogeneous `if`/`match` arms with a non-negative-literal alternative; #820 adds the heterogeneous-`@Int`-slot arm, closure argument, and closure return/capture obligations (each per-arm / per-site, with `@Int`-arm and `@Nat`-formal controls that must not fire) | | `test_int_widening_codegen.py` | 52 | 535 | #813 Stage 3 — runtime `@Nat -> @Int` widening-trap codegen: the codegen emits a guard at *exactly* the `@Nat -> @Int` coercion sites the verifier obligates (return, `let`, call argument, and — since #820 — array element, tuple construction/destructure, heterogeneous `if`/`match` arm, closure argument/return), so `vera run`/`vera compile` programs trap when a `@Nat` above i64.MAX would reinterpret to a negative `@Int` instead of silently returning the wrong value. The trap is a bare `unreachable` (shares `_emit_negative_i64_guard` with the #552 nat-bind guard), classified `kind="unreachable"` today (a dedicated widening trap kind is a follow-up) | | `test_int_widening_differential.py` | 26 | 320 | #813 verifier↔codegen behavioural differential (cross-component soundness rule): at every `@Nat -> @Int` coercion site the verifier's `nat_to_int_coerce` classification must AGREE with the runtime — a `tier3` (codegen-guarded) site MUST trap on a `@Nat` above i64.MAX (return / `let` / call-arg / constructor field / ADT sub-pattern / match-bind, and the #820 array-element / tuple-component / heterogeneous-arm / closure argument-return sites), while a `tier3_unguarded` (E531) site must NOT trap (the generic-instantiated `@Int`-field coercion codegen cannot guard). Runs BOTH sides on one corpus so the "runtime-guarded" claim is checked against the actual trap — catching a verifier deferral codegen never guards (unsound silent -1) or a spurious trap | @@ -127,7 +129,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_xmod_where_helper_import_991.py` | 3 | 190 | A non-generic where-helper's name (#991) no longer suppresses a same-named IMPORT's bare emission — the shadow set is collected from the POST-hoist program, so the import wins outside the parent (spec §5 helper locality) while the parent's body call reaches its own hoisted helper (`go(0) == 701`, both doors observed; a stale bare-name shadow would dangle `unknown func` or silently capture the import-bound call). Controls: a TOP-LEVEL local sharing an import's name still shadows it (§8.5.2), and an UNINSTANTIATED T-unused generic helper's name still shadows (its template still emits bare; dropping it would duplicate the import's bare emission) | | `test_generic_where_helper_990.py` | 10 | 339 | #990 nested-generic monomorphization: a `forall` where-helper under a NON-generic parent is a mono base — the issue repro (direct instantiation), the grandchild variant (all-non-generic ancestor chain), two instantiations (`T=Int` + `T=Bool`) both emitted, and WAT-level single-emission pins (exactly one `gid$Int` clone, no bare `@T` template); plus the #904 control (helper under a GENERIC parent stays hoisted per-clone, no standalone duplicate) and the own-where-child shape (the generic's T-dependent and T-independent children are hoisted per-clone only — the Pass-2 where-fn sweep stops at the generic template) | | `test_codegen_where_helper_mangling_991.py` | 13 | 553 | #991 non-generic where-helper name collisions: parent-qualified mangling (`compute$where$branchA$where$leaf`) so two siblings' same-named nested helpers, and a helper named like a top-level function, compile and run each their OWN body (RUN-value assertions — sibling `leaf`s summing to a value only distinct bodies yield, nested-helper vs top-level both reachable) instead of crashing WAT assembly with `duplicate func identifier`; plus WAT name-scheme pins (top-level names stay bare, nested helpers mangled), full lexical resolution (a grandchild calling an ancestor-scope "aunt", and an inner helper shadowing an outer same-named one), a collision coexisting with a nested generic (`gid$Int` still emitted), the generic-subtree capture battery — a generic helper's call to its OWN nested `shared` must not be captured onto an ancestor's hoisted name (silent-wrong-value shape, the false-Tier-1 verify+run differential, the unshadowed-ancestor-call no-regression guard, and a generic child's name shadowing an ancestor's) — and the CHECKER leg: a differing-signature diamond (`@Int -> @Int` vs `@Int -> @String` leaves) that the flat last-wins lookup falsely E121'd must check clean AND run to the three-subsystem-agreement value | -| `test_monomorphize_differential.py` | 51 | 1,789 | #732 differential soundness: the verifier's per-monomorphization instantiation discovery covers every instantiation codegen emits (name coverage + per-generic count), over real generic programs (conformance ch02/ch09, `examples/generics.vera`) plus inline cases for the soundness-critical scenarios — collapsed type vars, **prelude combinator emission** (`option_map`), transitive generics, a generic whose type arg is fixed only by a **where-helper's return** (a `Float64`-returning helper, so the unresolved-var `"Bool"` phantom default cannot mask a miss), a generic whose type arg is fixed only by an **imported constructor** (`id2(MkBox(7))` — the verifier's mono-context must include `_module_constructors`, else it phantom-defaults and misses codegen's `id2`), a generic whose type arg is fixed only by an **imported function's return** (`id_g(make_int(...))` — the verifier's mono-context must seed `fn_ret_types` from imported functions, else it phantom-defaults and misses codegen's `id_g`, plus a **private-shadow** case pinning the imported-fn seeding stays unfiltered like codegen since filtering would diverge into a false Tier-1), and a generic reached only through a **contract clause or `where` helper** (codegen must seed Pass 1.5 from the shared node-level walk, not just `decl.body`, or it skips the clone → `CodegenSkip` at run time) — so a missed instantiation (a false Tier-1) is caught. Guards against a vacuous pass when codegen emits nothing, plus a **determinism guard** (`vera compile --wat` is byte-stable across `PYTHONHASHSEED` — the mono worklist sorts its instantiation sets); plus the #899 **call-rewrite↔emitted-clone differential** (`test_call_rewrite_matches_emitted_clones`) — the THIRD consultor the verifier⊇codegen check never exercised: captures every mangled target the WASM call-rewriter (`_resolve_generic_call`) resolves and asserts each is an actually-emitted clone, over user-fn-return-into-generic-arg shapes (a non-generic user fn returning `Option`/`Result` in `Option`/`Result` position; a scalar-resolving alias `type Age = Int` and a named refinement in bare `@T` position; and a non-generic user fn returning a LITERAL parameterized type `Option<…>`/`Result<…>`/`Box<…>` bound to a bare `@T`, where discovery keys the clone by base name `pick_last$Option` — the base-name key is sound because a bare-`@T` body is representation-polymorphic) — a dangling target is the check-green-then-`run`-drops-`main` desync. All three consultors (discovery, verifier, call-rewrite) route the user-fn-return clone key through ONE shared `declared_return_clone_key`, so they cannot desync by construction. The #898 cross-argument merge (`eq2(MkErr(5), MkOk("x"))` — one argument fixes each of a sparse `Res`'s two parameters) is in BOTH corpora: a symmetric collapse of the merge trips the inline differential's vacuous-emission guard (codegen emits nothing once the type under-determines), and an asymmetric one-sided merge surfaces in the call-rewrite differential as a dangling bare `eq2$Res` clone. The #1274 per-module half lives here too: every QUALIFIED-ONLY module generic (private, out-of-filter, or locally shadowed) must be emitted AND discovered under the same `mod$$name` base, with the complement pinned beside it — a public in-filter unshadowed generic must keep the bare name, or #774's bare-call routing would break in silence | +| `test_monomorphize_differential.py` | 62 | 2,191 | #732 differential soundness: the verifier's per-monomorphization instantiation discovery covers every instantiation codegen emits (name coverage + per-generic count), over real generic programs (conformance ch02/ch09, `examples/generics.vera`) plus inline cases for the soundness-critical scenarios — collapsed type vars, **prelude combinator emission** (`option_map`), transitive generics, a generic whose type arg is fixed only by a **where-helper's return** (a `Float64`-returning helper, so the unresolved-var `"Bool"` phantom default cannot mask a miss), a generic whose type arg is fixed only by an **imported constructor** (`id2(MkBox(7))` — the verifier's mono-context must include `_module_constructors`, else it phantom-defaults and misses codegen's `id2`), a generic whose type arg is fixed only by an **imported function's return** (`id_g(make_int(...))` — the verifier's mono-context must seed `fn_ret_types` from imported functions, else it phantom-defaults and misses codegen's `id_g`, plus a **private-shadow** case pinning the imported-fn seeding stays unfiltered like codegen since filtering would diverge into a false Tier-1), and a generic reached only through a **contract clause or `where` helper** (codegen must seed Pass 1.5 from the shared node-level walk, not just `decl.body`, or it skips the clone → `CodegenSkip` at run time) — so a missed instantiation (a false Tier-1) is caught. Guards against a vacuous pass when codegen emits nothing, plus a **determinism guard** (`vera compile --wat` is byte-stable across `PYTHONHASHSEED` — the mono worklist sorts its instantiation sets); plus the #899 **call-rewrite↔emitted-clone differential** (`test_call_rewrite_matches_emitted_clones`) — the THIRD consultor the verifier⊇codegen check never exercised: captures every mangled target the WASM call-rewriter (`_resolve_generic_call`) resolves and asserts each is an actually-emitted clone, over user-fn-return-into-generic-arg shapes (a non-generic user fn returning `Option`/`Result` in `Option`/`Result` position; a scalar-resolving alias `type Age = Int` and a named refinement in bare `@T` position; and a non-generic user fn returning a LITERAL parameterized type `Option<…>`/`Result<…>`/`Box<…>` bound to a bare `@T`, where discovery keys the clone by base name `pick_last$Option` — the base-name key is sound because a bare-`@T` body is representation-polymorphic) — a dangling target is the check-green-then-`run`-drops-`main` desync. All three consultors (discovery, verifier, call-rewrite) route the user-fn-return clone key through ONE shared `declared_return_clone_key`, so they cannot desync by construction. The #898 cross-argument merge (`eq2(MkErr(5), MkOk("x"))` — one argument fixes each of a sparse `Res`'s two parameters) is in BOTH corpora: a symmetric collapse of the merge trips the inline differential's vacuous-emission guard (codegen emits nothing once the type under-determines), and an asymmetric one-sided merge surfaces in the call-rewrite differential as a dangling bare `eq2$Res` clone. The #1274 per-module half lives here too: every QUALIFIED-ONLY module generic (private, out-of-filter, or locally shadowed) must be emitted AND discovered under the same `mod$$name` base, with the complement pinned beside it — a public in-filter unshadowed generic must keep the bare name, or #774's bare-call routing would break in silence | | `test_codegen_expressions.py` | 89 | 787 | Int/Bool/Float64 literals, slot refs, arithmetic, comparison, boolean logic, unary ops, if/let, function calls, recursion, pipe operator, `CompileResult` surface (#419 split) | | `test_codegen_calls.py` | 32 | 1,402 | Statement-position unit calls (#556), **WASM tail-call optimization** (#517 — `return_call` emission, 50K- and 1M-iteration stress, structural `return_call`/plain-`call` boundary assertions, **GC-aware TCO for allocating fns** (#549 — `$gc_sp` restore before each `return_call`), postcondition-fallback regression, analyzer unit tests over tail-transparent constructs), pair-typed closure params + captures (#535) (#419 split) | | `test_codegen_infrastructure.py` | 24 | 455 | Module assembly import/memory conditionals, execute error paths, unsupported-construct skips + node-level E602 reasons (#626), built-in shadowing (#154), typed holes, example round-trips (#419 split) | @@ -179,7 +181,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_string_length_soundness.py` | 15 | 278 | #802 — string_length code-point vs UTF-8 byte soundness: a non-literal `string_length` defers to Tier 3 (the issue's `"é"` probe no longer proves `== 1` at Tier 1), a string-literal length is modeled at its exact UTF-8 byte count (`== 2` for `"é"`), and the boolean predicates `string_contains` / `string_starts_with` / `string_ends_with` stay Tier 1 (sound under UTF-8 self-synchronization), while a predicate over an astral (> U+2FFFF) or lone-surrogate literal defers to Tier 3 (z3.StringVal cannot model those code points) | | `test_errors.py` | 62 | 657 | Error code registry, diagnostic formatting, serialisation, SourceLocation, and error display sync — the canonical `E001` diagnostic must match each of its mirrors: `README.md`, `docs/index.html`, `spec/00-introduction.md`, `AGENTS.md`'s example `--json` block, and the hardcoded example in `scripts/build_site.py` that generates `docs/index.md` (#829; `AGENTS.md`'s ellipsis-truncated description/rationale are prefix-compared, its `error_code`/`spec_ref`/`fix` exactly) | | `test_eq_contract_874.py` | 13 | 430 | `eq`/`compare` ability ops in contract position: codegen canonicalization + verifier Tier-1 discharge/counterexample, where-fn contracts, compare Ordering-sort materialization, shadowing guard (#874) | -| `test_formatter.py` | 511 | 3,638 | Comment extraction, interior comment positioning, expression/declaration formatting, match arm block bodies, §1.8 rule 2 in value position (a `let`-bound `match`/`if` expands exactly as one in statement position, and a comment above an arm inside a statement's value stays on that arm), blank-line preservation (§1.8 rule 13 — gaps between statements, before a block result and around a comment, collapsed to one and never invented), idempotency, parenthesization, spec rules, ability declarations | +| `test_formatter.py` | 521 | 3,638 | Comment extraction, interior comment positioning, expression/declaration formatting, match arm block bodies, §1.8 rule 2 in value position (a `let`-bound `match`/`if` expands exactly as one in statement position, and a comment above an arm inside a statement's value stays on that arm), blank-line preservation (§1.8 rule 13 — gaps between statements, before a block result and around a comment, collapsed to one and never invented), idempotency, parenthesization, spec rules, ability declarations | | `test_cli.py` | 273 | 4,612 | CLI commands (check, verify, compile, run, serve, test, fmt, version, quiet), subprocess integration, JSON error paths (including the `verify --json` `obligations` array and its summary-reproducibility pin, #967, and the #1242 partition pin — the array is emitted unfiltered, a refuted obligation is counted by no summary field, and it still joins its E500 on the location key), runtime traps, arg validation, multi-file resolution, IO exit codes, --explain-slots (including the #1208 naming pins — an alias in type-argument position is tabled resolved, and a `forall` variable shadowing a module alias keeps the two parameter stacks apart — and the #1217 `where`-helper tables: the helper prints indented under its parent, appears in the JSON qualified as `parent.helper`, and inherits the enclosing `forall` variables so the shadowing holds inside it too), `builtins`/`effects`/`errors` introspection dispatch, and a USAGE-completeness guard (every dispatched `cmd_` handler has a help row) | | `test_introspect.py` | 39 | 221 | `vera builtins/effects/errors --json` registry introspection (#539): the `{schema, items}` envelope, count-equals-registry differential per registry, error-phase derivation, effect/ability `kind` tagging, the parameterised `Exn` effect, and best-effort `since` attribution with full-coverage guards | | `test_resolver.py` | 20 | 602 | Module resolution, path lookup, parse caching, circular import detection, the E011/E012/E013 diagnostic contract, internal-error isolation (a compiler bug is not masked as E013), and the transitive-closure return of `resolve_imports` (#890 — a diamond yields each reachable module once, direct imports tagged `direct`, the transitive one not) | @@ -197,7 +199,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_markdown.py` | 94 | 610 | Markdown parser: block/inline parsing, rendering, round-trips, edge cases | | `test_lsp.py` | 146 | 2470 | LSP transport + coordinate layer (#222 Phase C) and language features (#222 Phase D): parametrized code-point↔UTF-16 goldens incl. astral-plane fixtures and surrogate-pair snapping, Span (1-based, exclusive-end) and SourceLocation (0-based col) → LSP Range conversions, point→token-range widening, DocumentStore open/change/close + index invalidation, an in-process handler-drive test, and one stdio end-to-end round-trip against the real `vera lsp` subprocess (initialize → didOpen → shutdown → exit) pinning serverInfo + textDocumentSync capabilities; plus the Phase D feature suite — parse-error single-diagnostic path, type-error verification short-circuit, tier=3 in E520 diagnostic data, per-function tier Hint synthesis (and its suppression for functions with violated obligations), smallest-enclosing-span hover, De Bruijn slot goto (most-recent-parameter jump, out-of-range None, off-slot None, and the #1208 keying pins: a parameterised reference resolves, an alias-spelled parameter is reachable from a canonically-spelled reference, and a `forall` variable shadowing a module alias lands on the right parameter), and typed-hole completion (inside/after hole, away-from-hole None); plus the Phase E speculativeEdit suite — identical-text all-unchanged, breaking edit surfaces newly_undischarged (violated nat_sub) with canonical state untouched, strengthening edit surfaces newly_discharged, parse/type errors report ok:false, deleted functions report removed, proof_delta purity; plus the Phase F1 proposeEdit suite — the apply gate (clean and strengthening edits apply, breaking and non-compiling edits refuse), force overriding both gates with the delta still reported, wiring against a structural fake server (apply round-trip with exact full-document replacement range, refuse touches no canonical state, unopened-URI clamp sentinel), and full-document-range goldens (trailing-newline virtual line, UTF-16 end column); plus the Phase F2 strengthenContract suite — splice goldens (first-clause-only replacement with byte-identical remainder, ensures variant, unknown-fn None), the call-site audit pin (tightened precondition refused with newly_undischarged call_pre items, canonical state untouched), provable-ensures strengthening applies, and the three splice-target refusal paths (no analysis, unparseable document, unknown function); plus the Phase F3 addEffect suite — transitive-caller closure goldens (diamond in declaration order, leaf, unknown-fn None, recursion appears once), handler bounding (#725: a caller discharging the effect around its only call site drops out of the closure and is not rewritten, while a second unhandled path, a call in a handler clause, a handler for another effect, a handler naming a different instance of the same effect (`handle[State]` against a `State` propagation, which the checker does not discharge — end-to-end that caller must still be rewritten and the candidate must still apply, with the matching `State` propagation against the same fixture as the positive control that this handler key does prune something), and a bare `where`-helper call all keep it in, as does a refinement type argument at either depth (`Exn<{ @Int \| p }>` and `Exn>` render as their bare base type but discharge nothing of `Exn` — also pinned end-to-end), while an unparameterised `handle[IO]` does bound an `IO` propagation, a whitespace-spelled `State< Int >` request still bounds, and nesting bounds in either order (a matching handler inside a foreign one, a foreign one inside a matching one) — plus a key-level pin that `handle[Mod.IO]` keeps its module, and the effect-less query pinned handler-unaware; plus the two boundary pins the review added — a call in the handler's STATE INITIALISER keeps its edge, since the initialiser is evaluated in the enclosing scope before the handler is installed (pinned beside the E125 the checker raises there against a `pure` caller, with the identical call in the handler body clean as the contrast), and an alias-spelled handler does not bound a `State` propagation though the checker discharges it (`handle[State]` with `type MyAlias = Int` — the spelling comparison's under-prune, #1292, with the alias-spelled request as the control that does prune)), effect-row rewrite goldens (pure to singleton set, source-preserving append, already-present None, base-name identity blocking State next to State), diamond propagation applying one multi-site candidate with the bystander untouched, mixed append/replace rows with already-satisfied callers skipped, the fully-satisfied no-op shape, and the two refusal paths; plus the #728 instruction-contract suite — the LSP message carries description, rationale, and the Fix: paragraph (also pinning single E501 emission at the LSP surface), and a bare diagnostic maps to the description alone | | `test_browser.py` | 347 | 4,733 | Browser parity: Python/wasmtime vs Node.js/JS-runtime output equivalence across IO, State, contracts, Markdown, Regex, and the examples the browser target can execute (two explicit lists in the file, not the whole `examples/` directory — interactive stdin, file IO, `DB` and the non-standalone `modules` example are excluded with their reasons recorded); plus the #349 `runtime.mjs` coverage battery — per-value-type and per-key-type Map variants, per-element-type Set variants, cold `Decimal` branches (exact-zero sign, negative-shift division, `decRoundPlaces` special cases, non-finite storage), `readJson`/`json_stringify` across every Json ADT tag, the Regex/Json `Result.Err` arms, and nested-Markdown walks. Two operations carry a canonical form the specification states rather than merely agreeing across the hosts, so their batteries assert more than equality: `json_stringify` (spec §9.7.1) pins the expected string on every Json ADT tag and on the number-rendering boundaries, checks three-pass idempotence, checks that a non-finite `JNumber` fails on both hosts *and* prints nothing, pins the object key orders an ordinary JS object cannot carry (array-index keys, which ES enumeration hoists to the front in ascending numeric order, and a `__proto__` key, whose assignment writes a prototype instead of a field) on both a parsed and a program-built object, and checks the reference host's own number rendering differentially against a real `JSON.stringify` over doubles drawn from raw bit patterns; `md_render` (§9.7.3) pins the expected render, re-renders it to prove the fixed point, runs the round-trip property over a corpus carrying the container and multi-line shapes a flat corpus misses, and renders `MdBlock` values the test builds directly, since several renderer rules — a container's child separator, an empty container, a code span wider than one backtick — are unreachable through `md_parse`. `md_parse` itself is not yet at parity and its remaining divergence classes are a tracked bug ([#1301](https://github.com/aallan/vera/issues/1301)); the suite pins the shapes the two implementations agree on | -| `test_conformance.py` | 1080 | 125 | Parametrized conformance suite: parse, check, verify, run, format idempotency across 216 programs | +| `test_conformance.py` | 1130 | 125 | Parametrized conformance suite: parse, check, verify, run, format idempotency across 226 programs | | `test_prelude.py` | 29 | 585 | Prelude injection: Option/Result/array operation detection, combinator shadowing, type aliases, the reserved namespace every injected alias declaration lives in — checked against the checker's own E154 regex rather than a second spelling of the rule, since an alias the prelude injects outside it is one codegen resolves and the checker leaves opaque (#1184/#1221) — end-to-end compilation | | `test_checker_apply_fn.py` | 18 | 455 | #854 — `apply_fn` as a checker special form: zero-warning pins (API + CLI `--json` + closures.vera), E201 arity / E202 type / non-function-first-arg errors, E122/E125 effect-row enforcement for applied fn values, E151 redefinition rejection, variadic two-param application, prelude combinator regression pins | | `test_prelude_diagnostics.py` | 8 | 271 | #851 — prelude combinator skip-warnings: unreferenced-prelude E602/E604 suppression (zero-warning minimal compile, API + CLI `--json`), `` origin attribution for referenced-but-skipped combinators (text + `to_dict`), transitive reference scan, and user-fn warning locations pinned unchanged | @@ -218,11 +220,11 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_doc_builtin_shadowing.py` | 8 | 107 | `check_doc_builtin_shadowing.py` gate ([#819](https://github.com/aallan/vera/issues/819)): reject-set membership (opaque built-ins in, overridable combinators out), top-level + `where`-block `fn ` definitions flagged, non-built-in / overridable / prose-mention ignored, and the shipped docs are currently clean | | `test_runtime_traps.py` | 75 | 2,877 | Runtime trap categorisation (#516 Stage 1), out-of-bounds host-read bounds check (#1145), stdout/stderr-on-trap preservation (#522), `IO.print` live tee (#543), and trap source backtrace (#516 Stage 2): `_classify_trap` per-`kind` mapping (`divide_by_zero`/`out_of_bounds`/`stack_exhausted`/`unreachable`/`overflow`/`contract_violation`/`unknown`), `WasmTrapError` shape + `RuntimeError` substitutability, end-to-end `cmd_run` text + JSON envelopes including `trap_kind`, captured `stdout`, captured `stderr`, JSON-mode "no stderr leak" invariant, cross-stream code-order regression using merged `redirect_stdout`/`redirect_stderr`, the v0.0.123 tee suite (live streaming, write-count + order preservation, JSON-mode tee suppression, trap preservation invariant under tee, per-write flush count, default-execute silence), and the v0.0.124 source-mapping suite — `_resolve_trap_frames` unit tests covering user-fn / built-in / built-in-prefix / monomorphized base-name fallback / unknown-name / no-frames-attribute / leaf-first ordering preservation; end-to-end `cmd_run` text-mode + JSON-mode backtrace including the **leaf-first** ordering invariant; contract-violation backtrace in both text and JSON modes; direct `execute()` `WasmTrapError.frames` attachment; **suppression marker** for collapsed leading runtime-helper frames (mocked `vera.codegen.execute` with synthetic `is_builtin=True` leaf frames so the collapse logic is testable deterministically); source-map population for top-level fns + lifted closures (with span-value assertion against the closure literal's exact line range); and the no-builtin-leakage regression that pins built-in helpers (`alloc` / `gc_collect` / `contract_fail`) NOT being registered in `fn_source_map`; plus the v0.0.125 Stage 3 suite (`#547`) — text-mode `Fix:` block surfacing with position-ordering invariant (Fix appears after the source backtrace), text-mode block suppression for `contract_violation` (no empty header noise), JSON-mode `fix` field always-present (schema stability) including the empty-string case, `_TRAP_FIX_PARAGRAPHS` table-completeness assertion (every kind in the taxonomy has a Fix paragraph entry), and the column-wrap invariant (~76 chars max per line, two-space indent under the `Fix:` heading); plus the UTF-8 hardening suite **`TestHostPrintInvalidUtf8589`** (`#589` / `#592`) — after `#592` centralised the `errors="replace"` invariant into the single `vera.runtime.text.safe_utf8_decode` helper — reached only through a shared `_slice_and_decode` helper (`vera/runtime/heap.py`) that the three WASM-memory string readers (`_read_wasm_string` and `_read_string_export` there, and `vera/wasm/markdown.py::_read_string`) delegate to, with the `host_print` / `host_stderr` / `host_contract_fail` host imports and the String-return extractor in `execute()` routing through those readers rather than decoding inline: one helper unit test pinning the invariant once (invalid bytes → U+FFFD, valid + empty pass through), three wire-real end-to-end tests that drive the **production** readers (`_read_wasm_string` / markdown `_read_string` behind a synthetic-WAT `probe` host import; `_read_string_export` against a real exported memory, also covering its out-of-bounds → `None` pointer-fallback) over a region seeded with invalid UTF-8 — so a strict-decode regression surfaces as a `UnicodeDecodeError` escaping wasmtime's trampoline, and the host imports / extractor are transitively covered — and one synthetic-WAT end-to-end test that imports `vera.print` and calls it with raw invalid UTF-8 bytes to pin the wasmtime-trampoline fact independently (a Python `UnicodeDecodeError` inside a host import escapes as a "python exception" cause iff the host decode is strict); the six pre-`#592` structural source-grep assertions were retired by the centralisation; plus the Ctrl-C-during-host-import suite **`TestHostSleepKeyboardInterrupt`** ([#595](https://github.com/aallan/vera/issues/595) / [#599](https://github.com/aallan/vera/issues/599)) — after the v0.0.160 relocation to a single `except KeyboardInterrupt` handler in `execute()` (enabled by `wasmtime>=45.0.0`'s `except BaseException` trampoline fix): one structural assertion that the four per-host-import `raise _VeraExit(130)` guards are gone and the centralized handler maps to `exit_code=130`, plus four end-to-end tests that compile real Vera programs calling `IO.sleep(...)`, `IO.read_char(())`, a mocked fused `await` ([#841](https://github.com/aallan/vera/issues/841) — `Future.result()` patched to interrupt), and a live in-flight fused `await` (no mocking — `_thread.interrupt_main()` fired only once the server confirms the request arrived, handler then released so the executor teardown has a real worker to wait out; post-[#848](https://github.com/aallan/vera/issues/848) the progress print precedes the `async(...)`, so program order makes its stdout assertion deterministic), raise `KeyboardInterrupt` from inside the blocking call, and assert the program exits with `ExecuteResult.exit_code == 130` (pre-interrupt stdout preserved) instead of a raw Python traceback escaping wasmtime's trampoline | | `test_serve.py` | 8 | 189 | #305 `vera serve` driver end-to-end: GET/POST echo round-trips (method/path/headers/body cross the host↔guest boundary via `build_request_adt` / `decode_response_adt`), handler status propagation, runtime contract violation → 500 with `trap_kind` JSON, `State` isolation across requests (instance-per-request pinned), and clean `make_server` validation errors (missing / wrong-signature `handle`), and an eager-GC round-trip pinning the Request builder's shadow-rooting; all on ephemeral ports | -| `test_wasi_target.py` | 263 | 2,142 | #237 WASI Preview 2 target (spec chapter 13): component emission validated live against the real wasmtime host — parse (`Component(engine, wat)`), instantiate (`Linker.add_wasip2()` + `WasiConfig`), and execute (stdout/stderr capture, env, argv incl. a 500-arg GC-pressure stress and a >64 KiB arena-cap trap, preopen file round-trips + errno mapping, stdin incl. UTF-8 multibyte, clocks, random bounds, exit, contract-violation text on WASI stderr, overflow); the family gate (clean diagnostic naming unsupported families, never a silent fallback); the core-emission pin (default `--target wasm` WAT untouched); `cmd_compile`/`cmd_run --target wasi-p2` CLI integration (binary component artifact, `--wat` component text, JSON envelopes, trap-kind classification through the component boundary, exit-code 0/1 degradation, `--fn` rejection); the `execute_wasi_p2` host runner (env passthrough, argv, stderr capture, String-main `wasi:cli/run` fallback); the **dual-target conformance differential** (all 156 run-level conformance programs under both targets, byte-identical stdout/stderr required; nondeterministic-op and family-gated programs skip loudly); a stock-`wasmtime`-CLI smoke test (skips when the CLI is not installed); and the Stage-D **server world** (`world="server"`): incoming-handler emission pins (adapter lift, 32-slot dispatch table, @0.2.0 version pin, no `wasi:cli/run`), #305 handler validation + server family-gate diagnostics (rejected IO ops, non-String map instantiations, unsupported families), the cli-world pin (default emission carries no server machinery), Request/Response layout tripwires, and a stock-`wasmtime serve` smoke battery (host-vs-served differential over a method/path/header/body matrix incl. duplicate-header later-wins, in-guest map-op order parity, `IO.print` console routing, trap→500 with symbolized backtrace + violation text, graceful 500s for forbidden headers and out-of-range status, a 1 MiB GC-stress echo, and an eager-GC shadow-push mutation validation; skips when the CLI is not installed) | +| `test_wasi_target.py` | 267 | 2,142 | #237 WASI Preview 2 target (spec chapter 13): component emission validated live against the real wasmtime host — parse (`Component(engine, wat)`), instantiate (`Linker.add_wasip2()` + `WasiConfig`), and execute (stdout/stderr capture, env, argv incl. a 500-arg GC-pressure stress and a >64 KiB arena-cap trap, preopen file round-trips + errno mapping, stdin incl. UTF-8 multibyte, clocks, random bounds, exit, contract-violation text on WASI stderr, overflow); the family gate (clean diagnostic naming unsupported families, never a silent fallback); the core-emission pin (default `--target wasm` WAT untouched); `cmd_compile`/`cmd_run --target wasi-p2` CLI integration (binary component artifact, `--wat` component text, JSON envelopes, trap-kind classification through the component boundary, exit-code 0/1 degradation, `--fn` rejection); the `execute_wasi_p2` host runner (env passthrough, argv, stderr capture, String-main `wasi:cli/run` fallback); the **dual-target conformance differential** (all 156 run-level conformance programs under both targets, byte-identical stdout/stderr required; nondeterministic-op and family-gated programs skip loudly); a stock-`wasmtime`-CLI smoke test (skips when the CLI is not installed); and the Stage-D **server world** (`world="server"`): incoming-handler emission pins (adapter lift, 32-slot dispatch table, @0.2.0 version pin, no `wasi:cli/run`), #305 handler validation + server family-gate diagnostics (rejected IO ops, non-String map instantiations, unsupported families), the cli-world pin (default emission carries no server machinery), Request/Response layout tripwires, and a stock-`wasmtime serve` smoke battery (host-vs-served differential over a method/path/header/body matrix incl. duplicate-header later-wins, in-guest map-op order parity, `IO.print` console routing, trap→500 with symbolized backtrace + violation text, graceful 500s for forbidden headers and out-of-range status, a 1 MiB GC-stress echo, and an eager-GC shadow-push mutation validation; skips when the CLI is not installed) | ## Conformance Suite -The conformance suite is a collection of 216 small, focused programs in `tests/conformance/` that systematically validate every language feature against the spec. Most programs are self-contained; the module-focused Chapter 8 cases use `import` statements where needed, and `ch07_cross_module_contracts.vera` still depends on `ch07_cross_module_contracts_lib.vera`. Each program tests one feature or a small group of related features. +The conformance suite is a collection of 226 small, focused programs in `tests/conformance/` that systematically validate every language feature against the spec. Most programs are self-contained; the module-focused Chapter 8 cases use `import` statements where needed, and `ch07_cross_module_contracts.vera` still depends on `ch07_cross_module_contracts_lib.vera`. Each program tests one feature or a small group of related features. Simon Willison [argues](https://simonwillison.net/tags/conformance-suites/) that conformance suites are a "huge unlock" for language projects — they transform development from trust-based to verification-based. The conformance suite serves as the definitive specification artifact that any implementation (or agent) can validate against. @@ -247,11 +249,11 @@ Each conformance program declares the deepest pipeline stage it must pass: | Level | What it validates | Count | |-------|-------------------|------:| | `parse` | Source text is syntactically valid | 0 | -| `check` | Parses and type-checks cleanly | 39 | -| `verify` | Type-checks and all contracts verified by Z3 | 15 | -| `run` | Compiles to WASM and executes correctly | 162 | +| `check` | Parses and type-checks cleanly | 40 | +| `verify` | Type-checks and all contracts verified by Z3 | 20 | +| `run` | Compiles to WASM and executes correctly | 166 | -Almost all programs are at the `run` level — they compile and execute, producing correct results. Thirty-nine programs (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch03_typed_holes`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_cross_module_contracts_lib`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch07_bare_effect_op_rejected`, `ch08_circular_import`, `ch08_cross_module_generic_lib`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_transitive_module_import_base`, `ch08_visibility_private`, `ch08_xmod_widen_lib`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_eq_non_derivable_rejected`, `ch09_http`, `ch09_inference`, `ch09_ord_adt_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) are at the `check` level. Thirty-two of them — `ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch07_bare_effect_op_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, and `ch02_alias_cycle_rejected` — are **negative tests** that assert a specific diagnostic (E206, E135, E183, E201, E127, E153, E153, E153, E130, E130, E174, E182, E217, E011, E154, E154, E154, E154, E154, E154, E150, E152, E151, E242, E243, E207, E208, E208, E209, E128, E336, and E132 respectively) via the manifest's `expected_error` field; `ch09_http` and `ch09_inference` are environment-gated (network / API key). Fifteen programs (`ch03_slot_let_chains`, `ch03_slot_noncommutative`, `ch04_nested_option_ctor`, `ch04_primitive_obligations`, `ch05_apply_fn_typing`, `ch06_adt_sort_disambiguation`, `ch07_cross_module_contracts`, `ch07_io_read_char`, `ch07_io_sleep`, `ch07_random_effect`, `ch08_state_alias_module_table_lib`, `ch08_state_alias_per_module_lib`, `ch08_transitive_module_import_mid`, `ch09_http_server`, `ch09_math_builtins`) are at the `verify` level, using Z3-provable contracts — a library module is pinned at the deepest level it reaches, so the two per-module alias-table libraries are verified rather than only checked. +Almost all programs are at the `run` level — they compile and execute, producing correct results. Forty programs (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch03_typed_holes`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_cross_module_contracts_lib`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch07_bare_effect_op_rejected`, `ch08_circular_import`, `ch08_cross_module_generic_lib`, `ch08_module_generic_diamond_base`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_transitive_module_import_base`, `ch08_visibility_private`, `ch08_xmod_widen_lib`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_eq_non_derivable_rejected`, `ch09_http`, `ch09_inference`, `ch09_ord_adt_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) are at the `check` level. Thirty-two of them — `ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch07_bare_effect_op_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, and `ch02_alias_cycle_rejected` — are **negative tests** that assert a specific diagnostic (E206, E135, E183, E201, E127, E153, E153, E153, E130, E130, E174, E182, E217, E011, E154, E154, E154, E154, E154, E154, E150, E152, E151, E242, E243, E207, E208, E208, E209, E128, E336, and E132 respectively) via the manifest's `expected_error` field; `ch09_http` and `ch09_inference` are environment-gated (network / API key). Twenty programs (`ch03_slot_let_chains`, `ch03_slot_noncommutative`, `ch04_nested_option_ctor`, `ch04_primitive_obligations`, `ch05_apply_fn_typing`, `ch06_adt_sort_disambiguation`, `ch07_cross_module_contracts`, `ch07_invisible_import_op_name_lib`, `ch07_io_read_char`, `ch07_io_sleep`, `ch07_random_effect`, `ch08_state_alias_module_table_lib`, `ch08_module_generic_diamond_mid1`, `ch08_module_generic_diamond_mid2`, `ch08_state_alias_per_module_lib`, `ch08_transitive_module_import_mid`, `ch09_http_server`, `ch09_invisible_import_ability_op_lib`, `ch09_math_builtins`, `ch09_nested_helper_family_op_name_lib`) are at the `verify` level, using Z3-provable contracts — a library module is pinned at the deepest level it reaches, so the two per-module alias-table libraries are verified rather than only checked. ### Skipped tests @@ -370,7 +372,7 @@ tests/conformance/ ├── ch01_int_literals.vera # Chapter 1: Integer literals ├── ch01_float_literals.vera # Chapter 1: Float64 literals ├── ch01_string_escapes.vera # Chapter 1: String escape sequences -├── ... # 216 programs total, organized by spec chapter +├── ... # 226 programs total, organized by spec chapter ├── ch07_state_handler.vera # Chapter 7: State effect handler ├── ch07_exn_handler.vera # Chapter 7: Exn effect handler ├── ch09_numeric_builtins.vera # Chapter 9: Numeric built-in functions @@ -853,9 +855,9 @@ Twenty-eight scripts in `scripts/` validate cross-cutting concerns beyond unit t | Script | What it validates | |--------|-------------------| -| `check_conformance.py` | All 216 conformance entries hold at their declared level (parse/check/verify/run) — positives pass; the negatives fail `check` with their `expected_error` E-code | +| `check_conformance.py` | All 226 conformance entries hold at their declared level (parse/check/verify/run) — positives pass; the negatives fail `check` with their `expected_error` E-code | | `check_examples.py` | All 42 `.vera` examples pass `vera check` + `vera verify` | -| `check_corpus_canonical.py` | All 264 corpus programs (recursive over `examples/` + `tests/conformance/`) are in canonical form under `vera fmt` | +| `check_corpus_canonical.py` | All 274 corpus programs (recursive over `examples/` + `tests/conformance/`) are in canonical form under `vera fmt` | | `check_examples_readme.py` | Every `vera run` command in examples/README.md references an existing file and exported function | | `check_spec_examples.py` | 189 parseable code blocks from spec chapters: parse, type-check, and verify | | `check_readme_examples.py` | All Vera code blocks in README.md parse correctly | @@ -961,9 +963,9 @@ The repository configures 35 hooks across two stages: 33 run at the commit stage | `ruff check .` | Lint Python with ruff (default `F` + `E` rules) | | `mypy vera/` | Type-check compiler in strict mode | | `pytest tests/ -q` | Run full test suite | -| `check_conformance.py` | All 216 conformance entries hold at their declared level — positives pass; negatives fail `check` with their `expected_error` E-code | +| `check_conformance.py` | All 226 conformance entries hold at their declared level — positives pass; negatives fail `check` with their `expected_error` E-code | | `check_examples.py` | All 42 examples pass `vera check` + `vera verify` | -| `check_corpus_canonical.py` | All 264 `examples/` + `tests/conformance/` programs (recursive) are in canonical form (`vera fmt`) | +| `check_corpus_canonical.py` | All 274 `examples/` + `tests/conformance/` programs (recursive) are in canonical form (`vera fmt`) | | `check_examples_readme.py` | `vera run` commands in `examples/README.md` reference existing files and exported functions | | `check_readme_examples.py` | README code blocks parse correctly | | `check_examples_doc.py` | EXAMPLES.md code blocks parse correctly | diff --git a/docs/SKILL.md b/docs/SKILL.md index 674610ad..c45ccf48 100644 --- a/docs/SKILL.md +++ b/docs/SKILL.md @@ -2393,7 +2393,7 @@ public fn main(@Unit -> @Unit) ## Conformance Suite -The `tests/conformance/` directory contains 216 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. +The `tests/conformance/` directory contains 226 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. Each program is organized by spec chapter (`ch01_int_literals.vera`, `ch04_match_basic.vera`, `ch07_state_handler.vera`, etc.) and the `manifest.json` file maps features to programs. When you need to see how a specific construct works, check the conformance program before reading the spec. diff --git a/docs/index.html b/docs/index.html index abe6ded7..f32db32c 100644 --- a/docs/index.html +++ b/docs/index.html @@ -699,7 +699,7 @@

This page is also a machine-readable specificati Vera is under active development

- A complete compiler with 164 built-in functions, ten algebraic effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge), contract-driven testing via Z3, a language server with agent-facing proof deltas, and a 14-chapter specification. A 216-program conformance suite and 42 worked examples are validated against the spec on every pull request. All of it is developed openly on GitHub and released under the MIT licence. + A complete compiler with 164 built-in functions, ten algebraic effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge), contract-driven testing via Z3, a language server with agent-facing proof deltas, and a 14-chapter specification. A 226-program conformance suite and 42 worked examples are validated against the spec on every pull request. All of it is developed openly on GitHub and released under the MIT licence.

diff --git a/docs/index.md b/docs/index.md index 2970e4ef..103fefa5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -281,7 +281,7 @@ For other models: point them at [`SKILL.md`](https://veralang.dev/SKILL.md) via ## Status -Vera is under [active development](https://raw.githubusercontent.com/aallan/vera/main/ROADMAP.md). A complete compiler with 164 built-in functions, ten algebraic effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge), contract-driven testing via [Z3](https://www.microsoft.com/en-us/research/project/z3-3/), and a 14-chapter specification. A 216-program conformance suite and 42 worked examples are validated against the spec on every pull request. All of it is developed openly on [GitHub](https://github.com/aallan/vera) and released under the MIT licence. +Vera is under [active development](https://raw.githubusercontent.com/aallan/vera/main/ROADMAP.md). A complete compiler with 164 built-in functions, ten algebraic effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge), contract-driven testing via [Z3](https://www.microsoft.com/en-us/research/project/z3-3/), and a 14-chapter specification. A 226-program conformance suite and 42 worked examples are validated against the spec on every pull request. All of it is developed openly on [GitHub](https://github.com/aallan/vera) and released under the MIT licence. ## Links diff --git a/docs/llms-full.txt b/docs/llms-full.txt index e5759100..146fa583 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -2399,7 +2399,7 @@ public fn main(@Unit -> @Unit) ## Conformance Suite -The `tests/conformance/` directory contains 216 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. +The `tests/conformance/` directory contains 226 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. Each program is organized by spec chapter (`ch01_int_literals.vera`, `ch04_match_basic.vera`, `ch07_state_handler.vera`, etc.) and the `manifest.json` file maps features to programs. When you need to see how a specific construct works, check the conformance program before reading the spec. @@ -2478,7 +2478,7 @@ Read `SKILL.md` for the full language reference. It covers syntax, slot referenc ### Conformance programs as reference -The conformance suite in `tests/conformance/` contains 216 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-two negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) instead must *fail* `check` with the E-code in their `expected_error` field. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. +The conformance suite in `tests/conformance/` contains 226 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-two negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) instead must *fail* `check` with the E-code in their `expected_error` field. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. ### Workflow @@ -2657,9 +2657,9 @@ Each stage is a module with a single public API function (`parse_file`, `transfo pytest tests/ -v # Run all tests (see TESTING.md) pytest tests/test_conformance.py -v # Conformance suite only mypy vera/ # Type-check the compiler -python scripts/check_conformance.py # All 216 conformance programs hold (positives pass; negatives fail with their E-code) +python scripts/check_conformance.py # All 226 conformance programs hold (positives pass; negatives fail with their E-code) python scripts/check_examples.py # All 42 examples must pass -python scripts/check_corpus_canonical.py # All 264 corpus programs in canonical form +python scripts/check_corpus_canonical.py # All 274 corpus programs in canonical form ``` Test helpers follow a pattern: `_check_ok(source)` / `_check_err(source, match)` / `_verify_ok(source)` / `_verify_err(source, match)`. See existing tests for examples. @@ -2668,7 +2668,7 @@ When implementing a new language feature, write the conformance program *first* ### Invariants -- All 216 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) must *fail* `check` with their `expected_error` E-code +- All 226 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) must *fail* `check` with their `expected_error` E-code - All 42 examples in `examples/` must pass `vera check` and `vera verify` - `mypy vera/` must be clean - `pytest tests/ -v` must pass @@ -3213,7 +3213,7 @@ None of this is Vera-specific, but it validates the design choices. The thesis i This is a real concern. LLMs are trained on trillions of tokens of Python, TypeScript, and JavaScript. A MojoBench study (NAACL 2025) found that even fine-tuned models achieved only 30–35% improvement over base models on Mojo code generation, illustrating the cold-start problem for new languages. -Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (216 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification. +Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (226 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification. ## How does Vera compare to Dafny / Lean / Koka / F*? @@ -3256,7 +3256,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 10,803 tests, including a 216-program conformance suite +- 10,990 tests, including a 226-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/docs/llms.txt b/docs/llms.txt index 06ddf4c0..0e9cad56 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -56,4 +56,4 @@ Current version: 0.1.11. The reference compiler is written in Python. Install th - [TESTING.md](https://raw.githubusercontent.com/aallan/vera/main/TESTING.md): Test suite architecture, coverage data, and test conventions. - [KNOWN_ISSUES.md](https://raw.githubusercontent.com/aallan/vera/main/KNOWN_ISSUES.md): Known bugs and limitations. - [CONTRIBUTING.md](https://raw.githubusercontent.com/aallan/vera/main/CONTRIBUTING.md): Contribution guidelines. -- [Conformance Suite](https://github.com/aallan/vera/tree/main/tests/conformance): 216 programs validating every language feature against the spec. +- [Conformance Suite](https://github.com/aallan/vera/tree/main/tests/conformance): 226 programs validating every language feature against the spec. diff --git a/vera/README.md b/vera/README.md index f1663b12..7ff1b80a 100644 --- a/vera/README.md +++ b/vera/README.md @@ -92,7 +92,7 @@ execute(compile_result, ...) # → run WASM via wasmtime | ` calls.py` | 1,597 | | Function/constructor/module/ability calls | | | ` control.py` | 735 | | If/match, patterns, effect handlers | | | `resolver.py` | 332 | Resolve | Module path resolution, parse cache | `ModuleResolver` | -| `monomorphize.py` | 2,923 | Resolve | Shared generic instantiation discovery + AST substitution (verifier and codegen); each clone's De Bruijn recount renders its binder names under the **origin module's** `AliasEnv`, the one its consumers rebuild the clone's scope with (#1208) | `substitute_type_vars()`, `resolve_type_alias()`, `canonicalize_type_aliases()` | +| `monomorphize.py` | 3,357 | Resolve | Shared generic instantiation discovery + AST substitution (verifier and codegen); each clone's De Bruijn recount renders its binder names under the **origin module's** `AliasEnv`, the one its consumers rebuild the clone's scope with (#1208) | `substitute_type_vars()`, `resolve_type_alias()`, `canonicalize_type_aliases()` | | `smt.py` | 3,141 | Verify | Z3 translation layer; reads each callee's contract in the module that declared it (`_callee_contract_scope`), swapping the naming env its slots render against and the registry its bare-name calls resolve in as one `CalleeScope` (#1208, #1225) | `SmtContext`, `SlotEnv`, `CalleeScope` | | `verifier.py` | 9,177 | Verify | Contract verification; owns the per-module registries every rendering goes through — an imported callee's contract and an imported generic's clone are named, resolved, and quoted in the module that **declared** them (#1208, #1220, #1225) | `verify()` | | `wasm/` | 26,604 | Compile | WASM translation layer (package) | `WasmContext`, `WasmSlotEnv`, `StringPool` | @@ -131,7 +131,7 @@ execute(compile_result, ...) # → run WASM via wasmtime | ` api.py` | 1,402 | | Public API, dataclasses, `compile()`/`execute()` orchestration, core IO host bindings (#421) | | | ` memory.py` | 105 | | Compile-time ADT layout helpers (`ConstructorLayout`, alignment) (#421) | | | ` core.py` | 2,952 | | CodeGenerator class, orchestration, ability op rewriting (Pass 1.6), skip propagation to callers (#1100) | | -| ` modules.py` | 1,242 | | Cross-module registration + call detection (C7e), per-module alias + source scopes (#1111/#1186) — `_module_alias_scope` swaps the alias maps *and* the `AliasEnv` every codegen rendering goes through as one pair (#1208) | | +| ` modules.py` | 1,422 | | Cross-module registration + call detection (C7e), per-module alias + source scopes (#1111/#1186) — `_module_alias_scope` swaps the alias maps *and* the `AliasEnv` every codegen rendering goes through as one pair (#1208) | | | ` registration.py` | 499 | | Pass 1 forward declarations, ADT layout | | | ` monomorphize.py` | 1,534 | | Generic instantiation, type inference, ability constraint checking (Pass 1.5) | | | ` functions.py` | 1,430 | | Function body compilation, GC prologue/epilogue (Pass 2) | | @@ -752,7 +752,7 @@ The `ERROR_CODES` dict in `errors.py` maps every code to a short description (15 ## Test Suite -Testing spans a **pytest suite** of 10,803 tests across 164 files — compiler-internals unit tests plus a **conformance suite** (216 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. +Testing spans a **pytest suite** of 10,990 tests across 166 files — compiler-internals unit tests plus a **conformance suite** (226 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. See **[TESTING.md](../TESTING.md)** for the comprehensive testing reference -- test file table, conformance suite details, compiler code coverage, language feature coverage, helper conventions, validation scripts, CI pipeline, and guidelines for adding tests. From e54c572b7cac8fdec658a68da7b529663dcf1fd2 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 10:30:38 +0100 Subject: [PATCH 22/69] Make the matrix drivers fail when the fixture does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three instruments in the namespace matrices could go green while measuring nothing, and CodeRabbit found all three. `ModuleResolver.resolve_imports` records E011/E012 and returns what it resolved rather than raising, and the shared driver never read `resolver.errors` — so a misspelled module path dropped out silently. Measured: writing `lib.vera` as `liib.vera` leaves the private-wildcard cell answering 42007 with zero errors from every stage, which is the value that cell asserts. The driver now raises on resolution errors the same way it already raises on type-check errors, and for the same reason: no caller expects one, so it means the FIXTURE is broken. Both diamond joins were commutative. `door1(true) + door2(true) == 666` is satisfied by the two doors exchanging answers — the #1281 defect itself — and measured, a fixture with the two libraries' answers swapped still produced 666. Both sites now weight one contribution past the other's magnitude, so the pair is recoverable from the total; swapped answers now violate the program's own postcondition. `_errors(diags, code)` never matched on the code: descriptions do not carry it, so every match rode the "defined in both imported module" substring, which E609 and E610 share verbatim with E608 — one format string, three codes. The driver now returns `(error_code, description)` pairs and the filter matches the code, with a cell pinning that an ADT collision no longer satisfies a function-collision assertion. Two smaller instrument fixes ride along: WAT presence assertions move from prefix substrings to exact membership in the emitted-symbol list (a new `wat_fn_names` beside the existing boundary-safe helpers), since `"(func $mod$mid1$gen$Bool" in wat` is satisfied by any longer clone name; the absence assertions keep their unbounded substrings, which is the conservative direction for absence. And a fixture's body braces were doubled through an f-string, emitting `{{ 1 }}` — a block nested in a block, accepted only incidentally — while the `{{n}}` module-name placeholders it also carries are load-bearing and stay. Skip-changelog: test-instrument fixes and doc counts; no compiler change. Co-Authored-By: Claude --- tests/codegen_helpers.py | 19 +++++ tests/module_fixture_helpers.py | 38 +++++++-- tests/test_lexical_fn_scope_1299.py | 37 +++++--- tests/test_module_generic_collision_1281.py | 95 +++++++++++++++++---- 4 files changed, 152 insertions(+), 37 deletions(-) diff --git a/tests/codegen_helpers.py b/tests/codegen_helpers.py index 88fd2880..847bd850 100644 --- a/tests/codegen_helpers.py +++ b/tests/codegen_helpers.py @@ -91,6 +91,25 @@ def wat_fn_body(wat: str, name: str) -> str: return wat[match.start():match.end() + (nxt.start() if nxt else len(rest))] +def wat_fn_names(wat: str) -> list[str]: + """Every function symbol the module DEFINES, sorted. + + Membership against this list is exact, where ``"(func $f" in wat`` is a + prefix test that a longer mangled symbol satisfies — ``$gsib$Int`` is + matched by a check for ``$gsib``, and ``$gen$Bool`` by one for ``$gen``, + which is precisely how a monomorphized clone impersonates another. It + also makes a failure message useful: the assertion can print what WAS + emitted instead of only what was missing. + + Imports are excluded — ``(import … (func $vera.x …))`` is not a + definition, and a test asking "did we emit this?" never means the host's. + """ + return sorted( + m.group(1) + for m in re.finditer(r"(?m)^\s*\(func \$([^\s()]+)", wat) + ) + + def exceptions_engine() -> wasmtime.Engine: """A wasmtime engine configured the way ``execute()`` configures its own. diff --git a/tests/module_fixture_helpers.py b/tests/module_fixture_helpers.py index f60aa86b..63408843 100644 --- a/tests/module_fixture_helpers.py +++ b/tests/module_fixture_helpers.py @@ -129,7 +129,9 @@ def fake_resolved_module( def build_multi_module( tmp_path: Path, files: dict[str, str], main_name: str = "main.vera", -) -> tuple[list[str], CompileResult, list[str]]: +) -> tuple[ + list[tuple[str, str]], CompileResult, list[tuple[str, str]], +]: """Resolve + check + verify + compile *main_name* as ``vera run`` does. Returns ``(verify_errors, compile_result, codegen_errors)`` — the two @@ -140,10 +142,16 @@ def build_multi_module( answer is exactly the false Tier-1 shape these matrices hunt, and it is invisible to any test that inspects one side alone. - Type-check errors raise instead of being returned: every caller's - fixture is check-green by construction, so a check error means the - FIXTURE is broken, not the compiler — and returning it would let a - matrix cell pass vacuously with nothing compiled. + Each error is an ``(error_code, description)`` pair rather than a bare + description, so a caller pinning a specific diagnostic matches on the + CODE. ``_emit_collision_error`` renders E608, E609 and E610 from one + format string, so a description-substring match cannot tell a function + collision from an ADT one. + + Resolution and type-check errors RAISE instead of being returned: every + caller's fixture is well-formed and check-green by construction, so + either means the FIXTURE is broken, not the compiler — and returning + them would let a matrix cell pass vacuously with nothing assembled. Resolution goes through the real :class:`~vera.resolver.ModuleResolver` rooted at *tmp_path*, so each module's ``direct`` flag is the @@ -158,6 +166,20 @@ def build_multi_module( program = parse_to_ast(source) resolver = ModuleResolver(_root=tmp_path) resolved = resolver.resolve_imports(program, main_path) + # Resolution failures are RECORDED, not raised: `resolve_imports` appends + # E011/E012 to `resolver.errors` and returns whatever it did resolve. An + # unresolved module therefore drops out silently, and a cell whose + # expected answer is the effect operation — most of the matrix — still + # gets that answer and passes while measuring nothing. Measured: with + # `lib.vera` written as `liib.vera`, the private-wildcard cell returns + # 42007 and every stage reports zero errors. + # + # Raised the same way as the type-check errors below, and for the same + # reason: no caller expects one, so a resolution error means the FIXTURE + # is broken, not the compiler — and returning it would let a cell pass + # with nothing assembled. + resolve_errors = [d.description for d in resolver.errors] + assert not resolve_errors, f"module resolution errors: {resolve_errors}" diags, arts = typecheck_with_artifacts( program, source, file=str(main_path), resolved_modules=resolved, collect_module_artifacts=True, @@ -167,7 +189,8 @@ def build_multi_module( vres = verify(program, source, file=str(main_path), resolved_modules=resolved) verify_errors = [ - d.description for d in vres.diagnostics if d.severity == "error" + (d.error_code, d.description) + for d in vres.diagnostics if d.severity == "error" ] result = codegen_compile( program, source=source, file=str(main_path), resolved_modules=resolved, @@ -176,7 +199,8 @@ def build_multi_module( module_artifacts=arts.module_artifacts, ) cg_errors = [ - d.description for d in result.diagnostics if d.severity == "error" + (d.error_code, d.description) + for d in result.diagnostics if d.severity == "error" ] return verify_errors, result, cg_errors diff --git a/tests/test_lexical_fn_scope_1299.py b/tests/test_lexical_fn_scope_1299.py index ddf98b18..e2fd7ab4 100644 --- a/tests/test_lexical_fn_scope_1299.py +++ b/tests/test_lexical_fn_scope_1299.py @@ -51,7 +51,7 @@ import pytest -from tests.codegen_helpers import wat_calls, wat_fn_body +from tests.codegen_helpers import wat_calls, wat_fn_body, wat_fn_names from tests.module_fixture_helpers import build_multi_module, module_value from vera import ast from vera.codegen.core import CodeGenerator @@ -68,6 +68,13 @@ LIB = 7007 LOCAL = 555 +# Where one program's answer is the join of TWO measured values, the join is +# weighted rather than summed. `a + b` is commutative, so it is satisfied by +# the two contributions swapping — which is the defect class these files +# exist to catch, not an unrelated one. Scaling one past the other's +# magnitude keeps the pair recoverable from the total. +JOIN_SCALE = 1000 + # --------------------------------------------------------------------- # Fixture builders @@ -341,7 +348,7 @@ def test_wildcard_import_exposes_a_public_name( requires(true) ensures(true) effects(pure) -{{ holder(true) + sibling(()) }} +{{ holder(true) * {JOIN_SCALE} + sibling(()) }} """ @@ -380,7 +387,7 @@ def test_the_generic_still_reaches_its_own_helper( ) assert not cg_errors, f"codegen errors: {cg_errors}" assert wat_calls(result.wat, "holder$Bool$where$get") - assert module_value(result) == ("ok", LIB + CELL) + assert module_value(result) == ("ok", LIB * JOIN_SCALE + CELL) # --------------------------------------------------------------------- @@ -1106,10 +1113,11 @@ def test_an_imported_generic_is_emitted_only_as_clones( "import lib(touch);\n\n", "touch(())")}, ) assert not cg_errors, f"codegen errors: {cg_errors}" - assert "(func $holder " not in result.wat, ( - "an imported generic template was emitted under its bare name — " - "the Pass-2.5 door would then need the scope the mono door does " - "not" + emitted = wat_fn_names(result.wat) + assert "holder" not in emitted, ( + f"an imported generic template was emitted under its bare name — " + f"the Pass-2.5 door would then need the scope the mono door does " + f"not; emitted: {emitted}" ) clone = wat_fn_body(result.wat, "mod$lib$holder$Bool") assert wat_calls(clone, "mod$lib$holder$Bool$where$get") @@ -1208,7 +1216,7 @@ class TestGuardRailKeepsTheFlatTable: requires(true) ensures(true) effects(pure) -{{ lib::touch(()) + touch(()) }} +{{ lib::touch(()) * {JOIN_SCALE} + touch(()) }} """ def test_qualified_call_still_reaches_the_shadowed_module_body( @@ -1218,7 +1226,7 @@ def test_qualified_call_still_reaches_the_shadowed_module_body( tmp_path, {"lib.vera": self._SHADOWED_LIB, "main.vera": self._QUALIFIED_MAIN}, - ) == LIB + LOCAL + ) == LIB * JOIN_SCALE + LOCAL class TestDiscoveryScopeIsPerNamespace: @@ -1458,11 +1466,14 @@ def test_the_helper_family_instantiates_at_the_checkers_type( tmp_path, dict(self._FILES), ) assert not cg_errors, f"codegen errors: {cg_errors}" - assert "(func $outer$Int$where$gsib$Int" in result.wat, ( - f"the helper family was instantiated at the wrong type; emitted " - f"symbols: " - f"{sorted(n for n in result.wat.split() if n.startswith('$outer'))}" + emitted = wat_fn_names(result.wat) + assert "outer$Int$where$gsib$Int" in emitted, ( + f"the helper family was instantiated at the wrong type; " + f"emitted symbols: {emitted}" ) + # Left as an unbounded substring on purpose: this asserts ABSENCE, so + # matching any symbol that merely contains the wrong instantiation is + # the conservative direction. assert "$where$gsib$Bool" not in result.wat, ( "a clone was named from the invisible module declaration's " "return type" diff --git a/tests/test_module_generic_collision_1281.py b/tests/test_module_generic_collision_1281.py index 6be4f58d..2e78646d 100644 --- a/tests/test_module_generic_collision_1281.py +++ b/tests/test_module_generic_collision_1281.py @@ -39,6 +39,7 @@ import pytest +from tests.codegen_helpers import wat_fn_names from tests.module_fixture_helpers import build_multi_module, module_value BASE_ANSWER = 111 @@ -92,15 +93,24 @@ {{ gen(@Bool.0) }} """ +# The join is WEIGHTED, not a sum. `door1 + door2` is commutative, so the +# two doors could swap answers — precisely the defect this file is about — +# and the total would be unchanged: measured, a fixture with the two +# libraries' answers exchanged still produced 666. Scaling one contribution +# past the other's magnitude makes the pair recoverable from the total, so +# each door's answer is pinned individually by one assertion. +DIAMOND_SCALE = 1000 +DIAMOND_TOTAL = MID1_ANSWER * DIAMOND_SCALE + BASE_ANSWER + _DIAMOND_MAIN = f"""\ import mid1(door1); import mid2(door2); public fn main(@Unit -> @Int) requires(true) - ensures(@Int.result == {MID1_ANSWER + BASE_ANSWER}) + ensures(@Int.result == {DIAMOND_TOTAL}) effects(pure) -{{ door1(true) + door2(true) }} +{{ door1(true) * {DIAMOND_SCALE} + door2(true) }} """ @@ -122,8 +132,19 @@ def _standalone(module_src: str, door: str, answer: int) -> str: """ -def _errors(diags: list[str], code: str) -> list[str]: - return [d for d in diags if code in d or "defined in both" in d] +def _errors( + diags: list[tuple[str, str]], code: str, +) -> list[tuple[str, str]]: + """The diagnostics carrying *code*, matched on the CODE. + + Matched on ``Diagnostic.error_code``, never on the description: the + description never contains the code, so a substring filter fell through + to the "defined in both imported module" wording — which E609 (data + types) and E610 (constructors) share verbatim, because they are the same + ``_emit_collision_error`` call with a different ``kind``. Every positive + assertion below wants E608 specifically. + """ + return [(c, d) for c, d in diags if c == code] def _answer( @@ -138,6 +159,30 @@ def _answer( return payload +def test_the_collision_filter_matches_on_the_code_not_the_wording() -> None: + """``_errors`` distinguishes E608 from its same-worded siblings. + + ``_emit_collision_error`` produces E608 (functions), E609 (data types) + and E610 (constructors) from ONE format string, so all three read + "… is defined in both imported module …". A description-substring + filter therefore matched any of them, and every positive assertion in + this file would have been satisfied by an ADT collision. + """ + diags = [ + ("E609", "Data type 'gen' is defined in both imported module " + "'a' and 'b'."), + ("E610", "Constructor 'Gen' is defined in both imported module " + "'a' and 'b'."), + ] + assert _errors(diags, "E608") == [] + assert _errors([*diags, ("E608", "Function 'gen' is defined in both " + "imported module 'a' and 'b'.")], + "E608") == [ + ("E608", "Function 'gen' is defined in both imported module " + "'a' and 'b'."), + ] + + class TestStandaloneOracles: """What each library's source commits to, with no diamond in the picture.""" @@ -179,14 +224,17 @@ class TestDiamond: def test_compiles_and_each_door_runs_its_own_generic( self, tmp_path: Path, ) -> None: - assert _answer(tmp_path, dict(self._FILES)) == ( - MID1_ANSWER + BASE_ANSWER - ) + # Weighted, so the two doors' answers are separable from the total — + # a plain sum is satisfied by them swapping, which is the defect. + assert _answer(tmp_path, dict(self._FILES)) == DIAMOND_TOTAL def test_no_collision_diagnostic(self, tmp_path: Path) -> None: _, result, _ = build_multi_module(tmp_path, dict(self._FILES)) + # Every diagnostic, not just the errors: an E608 demoted to a warning + # would still be the rail firing on a pair it must not refuse. collisions = _errors( - [d.description for d in result.diagnostics], "E608", + [(d.error_code, d.description) for d in result.diagnostics], + "E608", ) assert not collisions, f"E608 still refuses the pair: {collisions}" @@ -196,12 +244,17 @@ def test_the_two_clones_are_distinct_symbols( """The classification's claim, read off the emitted module: nothing is emitted under a bare ``gen``, and each owner has its own clone.""" _, result, _ = build_multi_module(tmp_path, dict(self._FILES)) + emitted = wat_fn_names(result.wat) + # Absence, so the unbounded prefix is the conservative direction: ANY + # `gen$…` in the entry's bare clone namespace is the failure. assert "(func $gen$" not in result.wat, ( - "a generic was emitted in the ENTRY's bare clone namespace, " - "where neither owner belongs" + f"a generic was emitted in the ENTRY's bare clone namespace, " + f"where neither owner belongs; emitted: {emitted}" ) - assert "(func $mod$mid1$gen$Bool" in result.wat - assert "(func $mod$base$gen$Bool" in result.wat + # Presence, so matched EXACTLY — `"(func $mod$mid1$gen$Bool" in wat` + # is a prefix test a longer mangled clone would satisfy. + assert "mod$mid1$gen$Bool" in emitted, emitted + assert "mod$base$gen$Bool" in emitted, emitted class TestTwoTransitiveImporters: @@ -231,15 +284,19 @@ class TestTwoTransitiveImporters: .replace("door2", "doorb") .replace(f"== {BASE_ANSWER}", f"== {DEEPB_ANSWER}") ) + # Weighted for the same reason as the diamond above: the two importers + # exchanging dependencies is exactly the failure, and a sum cannot see it. + _TOTAL = BASE_ANSWER * DIAMOND_SCALE + DEEPB_ANSWER + _MAIN = f"""\ import mida(doora); import midb(doorb); public fn main(@Unit -> @Int) requires(true) - ensures(@Int.result == {BASE_ANSWER + DEEPB_ANSWER}) + ensures(@Int.result == {_TOTAL}) effects(pure) -{{ doora(true) + doorb(true) }} +{{ doora(true) * {DIAMOND_SCALE} + doorb(true) }} """ def test_each_importer_reaches_its_own_dependency( @@ -249,7 +306,7 @@ def test_each_importer_reaches_its_own_dependency( "deepa.vera": self._DEEPA, "deepb.vera": self._DEEPB, "mida.vera": self._MIDA, "midb.vera": self._MIDB, "main.vera": self._MAIN, - }) == BASE_ANSWER + DEEPB_ANSWER + }) == self._TOTAL class TestQualifiedOnlyGenericsAreKeyedPerOwner: @@ -681,6 +738,10 @@ def test_two_non_generics_still_collide( ) -> None: """Non-generics are untouched: each really is emitted under the bare ``$name`` in Pass 2.5, whatever its visibility.""" + # `{{n}}` stays doubled — it is the literal `{n}` placeholder the + # `.replace` below fills in. The BODY braces were doubled too, which + # emitted `{{ 1 }}`: a block nested in a block, accepted only + # incidentally by the parser and unlike every other fixture here. lib = f"""\ module lib{{n}}; @@ -688,13 +749,13 @@ def test_two_non_generics_still_collide( requires(true) ensures(true) effects(pure) -{{{{ 1 }}}} +{{ 1 }} public fn door{{n}}(@Unit -> @Int) requires(true) ensures(true) effects(pure) -{{{{ plain(()) }}}} +{{ plain(()) }} """ main = """\ import lib1(door1); From ca1043cd13b4cb76b5c317d0bc2289f555227338 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 10:31:04 +0100 Subject: [PATCH 23/69] Anchor the #1304 claim to its measurement, and re-derive two counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TESTING.md carried two free-form counts the oracle cannot see, both left behind by the rebase: "224 conformance programs" in a table row's prose and "1,080 tests" in the conformance-suite run instructions. They are 226 and 1,130. A sweep of the file's other free-form counts found the rest already agreeing with their table rows; the two that do not name different quantities (a past mutation measurement, and the slot-naming differential's corpus size), so they stay. ROADMAP's #1304 entry keeps its wording and gains its provenance. Review read the issue BODY, which records the older injection-order framing, and asked for "selects the last import" — but that is refuted by the measurement in the issue thread, and restoring it would make the roadmap state something false: six consecutive `vera check` runs of one unchanged file give different answers, and under a fixed PYTHONHASHSEED the winner tracks module-name hash order rather than import position. Issue bodies are immutable by convention here and corrections ride comments, so the entry now links the measurement comment directly. The claim is also tightened to what was measured: E608 refuses the shape at compile, so the flap is observable only at `vera check`. Counts re-derived from the oracle after both changes. Skip-changelog: documentation counts and one roadmap citation. Co-Authored-By: Claude --- FAQ.md | 2 +- README.md | 2 +- ROADMAP.md | 4 ++-- TESTING.md | 8 ++++---- docs/llms-full.txt | 2 +- vera/README.md | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/FAQ.md b/FAQ.md index 10fb7f06..6b67d906 100644 --- a/FAQ.md +++ b/FAQ.md @@ -279,7 +279,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 10,990 tests, including a 226-program conformance suite +- 10,991 tests, including a 226-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/README.md b/README.md index c8c67ffe..23c08cfa 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ cp /path/to/vera/SKILL.md ~/.claude/skills/vera-language/SKILL.md ## Project status -Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 10,990 tests, 95% Python code coverage, 226 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. +Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 10,991 tests, 95% Python code coverage, 226 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. The reference compiler — parser, AST, type checker, contract verifier (Z3), WASM code generator, module system, browser runtime, and runtime contract insertion — is working. The language specification is in draft across [14 chapters](spec/). diff --git a/ROADMAP.md b/ROADMAP.md index 5594f831..50023846 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,7 +12,7 @@ Ordering derives from the design principles ([DESIGN.md](DESIGN.md)): verificati ## Where we are -10,990 tests, 226 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. +10,991 tests, 226 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. ## Stage 19 — The verification completeness sprint @@ -137,7 +137,7 @@ Beyond the staged sprints — grouped by arc, each pulled forward by its trigger **Concurrency and WASI** — [#406](https://github.com/aallan/vera/issues/406) WASI 0.3 native async (gated on wasmtime-py exposing component async), [#853](https://github.com/aallan/vera/issues/853) extend wasi-p2 beyond IO+Random (Http via `wasi:http` outgoing-handler, streaming filesystem, sockets), [#270](https://github.com/aallan/vera/issues/270) `handle[Async]` scheduling strategies, [#227](https://github.com/aallan/vera/issues/227) timeout/cancellation effects, [#228](https://github.com/aallan/vera/issues/228) WebSocket/SSE, [#770](https://github.com/aallan/vera/issues/770) non-blocking / timed stdin, [#844](https://github.com/aallan/vera/issues/844) advisory diagnostic for shape-unfusable `async` arguments. -**Modules and ecosystem** — [#1304](https://github.com/aallan/vera/issues/1304) settle which import supplies a bare name when two of them do (spec §8.5 defines no order, and the checker's pick is a set-iteration artefact that flips between runs of the identical program rather than an order at all; E608 refuses the shape before it can matter), [#187](https://github.com/aallan/vera/issues/187) module-qualified call disambiguation → [#127](https://github.com/aallan/vera/issues/127) module re-exports, [#130](https://github.com/aallan/vera/issues/130) package system and registry, [#163](https://github.com/aallan/vera/issues/163) standalone WASM runtime package, [#238](https://github.com/aallan/vera/issues/238) Component Model interop, [#56](https://github.com/aallan/vera/issues/56) incremental compilation, [#294](https://github.com/aallan/vera/issues/294) effect row variable unification, [#785](https://github.com/aallan/vera/issues/785) GitHits MCP (bookmark; trial at the next dependency-facing milestone). +**Modules and ecosystem** — [#1304](https://github.com/aallan/vera/issues/1304) settle which import supplies a bare name when two of them do — spec §8.5 defines no order, and the checker's pick is a set-iteration artefact that flips between runs of the identical program rather than an order at all ([measured](https://github.com/aallan/vera/issues/1304#issuecomment-5289022950): six consecutive `vera check` runs of one file, stable under a fixed `PYTHONHASHSEED` but tracking module-name hash order rather than import position), with E608 refusing the shape at compile so the flap is observable only at `vera check`, [#187](https://github.com/aallan/vera/issues/187) module-qualified call disambiguation → [#127](https://github.com/aallan/vera/issues/127) module re-exports, [#130](https://github.com/aallan/vera/issues/130) package system and registry, [#163](https://github.com/aallan/vera/issues/163) standalone WASM runtime package, [#238](https://github.com/aallan/vera/issues/238) Component Model interop, [#56](https://github.com/aallan/vera/issues/56) incremental compilation, [#294](https://github.com/aallan/vera/issues/294) effect row variable unification, [#785](https://github.com/aallan/vera/issues/785) GitHits MCP (bookmark; trial at the next dependency-facing milestone). **Standard library long tail** — [#367](https://github.com/aallan/vera/issues/367) Markdown extractors, [#368](https://github.com/aallan/vera/issues/368) HTML accessors, [#507](https://github.com/aallan/vera/issues/507) ability-dispatched array operations, [#509](https://github.com/aallan/vera/issues/509) Unicode-aware string built-ins phase 2, [#1143](https://github.com/aallan/vera/issues/1143) `` effect phases 2–3 — named columns (via Map), typed rows (via JSON), and further backends. diff --git a/TESTING.md b/TESTING.md index 07fbffa5..21c297e4 100644 --- a/TESTING.md +++ b/TESTING.md @@ -6,7 +6,7 @@ This is the single source of truth for Vera's testing infrastructure, coverage d | Metric | Value | |--------|-------| -| **Tests** | 10,990 across 166 files (~155,000 lines of test code; 10,813 passed + 26 stress, 151 skipped) | +| **Tests** | 10,991 across 166 files (~155,000 lines of test code; 10,814 passed + 26 stress, 151 skipped) | | **Compiler code coverage** | 95% Python, 87% JavaScript (CI minimum: 80%) | | **Conformance programs** | 226 programs across 9 spec chapters, validating every language feature | | **Example programs** | 42, all validated through `vera check` + `vera verify` | @@ -82,8 +82,8 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_import_visibility_entry_point_1244.py` | 6 | 269 | #1244 — `vera check` reports the same diagnostics whether it was given a module or a file that imports it. Registration alone says what a module DECLARES; the importer never checked its bodies, so a name a module never imported was rejected standalone (E200, §8.5.1) and accepted in silence through an importer. Written as EQUALITY between the two entry points rather than as "the importer warns", because the property is agreement — a future change making the standalone verdict lenient would satisfy a one-sided assertion and must fail here on the standalone leg. Six cases: the leaked unimported name, the honest control that imports what it uses (green before and after, so the new body check is the visibility rule rather than a blanket rejection of cross-module programs), the issue's type-error-through-importer shape (an `@Int` call bound to a `@Bool` slot: check-clean, Tier-1, failing at compile), a diamond proving each module is reported ONCE (the body check is memoised by path across nested checkers), and both entry points into an import cycle proving the memo terminates it | | `test_clone_body_declaring_module_1241_1243.py` | 5 | 344 | #1241 + #1243 — an imported generic's clone body resolves its bare calls in the DECLARING module, on both sides. The verifier's lexical lookup fell through to the importer's registry (`_declaring_module_scope` swapped the naming env, source and file but not the function registry) and codegen's clone-emission door was the one door that did not thread the module's intra-rename map, so `glib`'s `gen` called the importer's `need`. The two halves are one routing rule, and the tests are written so neither passes alone: each case asserts the `vera verify` verdict AND the `vera run` value together, so the verifier half alone (which makes verify clean while the compiled program still traps on the postcondition it just proved — the measured false Tier-1) fails the same test the codegen half alone (right value, verify still refusing) fails. Every expected value comes from the module verified and run STANDALONE, never from what the importer produces. Shapes: a private direct callee, a two-hop private chain, the type-discriminating pair (`@Int` vs `@Bool` — check-green source that emitted invalid WASM), and the unshadowed-callee control that was correct before and after, which is what pins the defect to the SHADOWED name rather than to cross-module calls in general | | `test_module_generic_namespace_1274.py` | 24 | 1,015 | #1274 — a module generic that does not own the importer's bare name is reached under `mod$$name`. Pre-fix only PRIVATE module generics were routed that way (#1000), so a PUBLIC one collided with the importer's same-named generic in the clone-name space: both files' `gen2` mangled to one `gen2$Bool`, one overwrote the other, and the module's own body ran the importer's — a **false Tier-1** (`check`/`verify` clean, the module's proved `ensures` violated at run: 999 where the declaring module answers 111). The full visibility matrix (module generic × importer generic), the import-filter dimension (out-of-filter, in-filter, wildcard), the unshadowed-out-of-filter cell that assembled to `unknown func $gen2`, and a type-discriminating shape whose two clones have different WAT result types. Each cell asserts the verify VERDICT and the runtime VALUE together in one test — a clean verify beside a violated postcondition IS the bug, so splitting them across sibling tests would let it hide — against the standalone library as oracle, and re-checks that the importer's own generic still answers its own value. The per-module both-sides differential lives in `test_monomorphize_differential.py`. Two further families joined after the adversarial round: the module→module **hop** — a module's bare call to a DIFFERENT module's qualified-only generic, which the per-module classification never rerouted, in both a LOUD spelling (a contract pins the answer, so a captured call traps) and a SILENT one (every contract admits both answers, so only the value distinguishes them) plus the two-hop shape where the entry never imports the declaring module at all; and the **shared-input** pair, which pins that the two sides compute the importer's occupied bare names identically — codegen reads them after Pass 0's helper renames, the verifier from the pre-transform AST, and a non-generic `where`-helper named `gen2` made the same imported generic bare-name-owning on one side and qualified-only on the other. The idempotence of that derivation is asserted directly across BOTH Pass-0 transforms and their composition — over a fixture carrying every helper shape it distinguishes (non-generic under non-generic, under a generic parent, under a generic helper, and a generic helper), since a fixture missing one would let a partial assertion look total — with each shape's membership pinned individually beside it, because idempotence alone would hold for a derivation that answered the same WRONG set every time. Two more families close the visibility dimension: the **transitive** one, driven through the production `ModuleResolver` (a hand-built `ResolvedModule` defaults `direct=True` and would never reach the path), asserting that a module reached only transitively has ALL its generics qualified-only — the entry's namespace does not hold them at all; and the **user-written qualified call** (`deep::gen(true)` where the importer declares its own `gen`), which must key its instantiation to the module's declaration rather than to whoever owns the bare name | -| `test_module_generic_collision_1281.py` | 19 | 717 | #1281 — E608 must not refuse two modules' PROVABLY DISTINCT generics. A generic emits nothing under its bare name, and since #1274 its clones live in a namespace chosen per OWNER, so the diamond (`base` public, `mid1` private, both named `gen`) cannot overwrite anything — and was refused outright, with `vera verify` returning rc=0 beside the refusal. Each door now answers its own module's generic (555 + 111) against the standalone oracles, and the emitted module carries `mod$…$mid1$gen$Bool` and `mod$…$base$gen$Bool` with nothing in the entry's bare clone namespace. The relaxation is gated on three conditions, each with its own cell: both declarations are top-level generics (a generic beside a non-generic keeps the refusal), at most one owns the bare name (asked of the predicate directly, since end to end the ambiguity gate catches that shape first), and no namespace can name both — a module importing two dependencies that each export `gen` resolves its own bare call to one of them, and neither spec §8.5 nor the checker says which: the checker's pick is a set-iteration artefact, and over eight runs of one unchanged program the type oracle accepted four times and was rejected four times (stable under a fixed `PYTHONHASHSEED`, varying with the seed, and identical whichever textual order the imports are written in), while codegen's reroute map is positional last-wins. Compiling the shape would pick a body by coin-flip, so it keeps its loud refusal (issue 1304). A namespace that declares its own `gen` is not ambiguous however many dependencies export one (§8.5.2). The registration half — a qualified-only generic contributing no bare `_fn_sigs` or `_fn_ret_type_exprs` entry — is pinned by two STRUCTURAL cells, one per table, and its docstring says why: with #1299's scope narrowing in place both withholdings are defence in depth, reverting them leaves every suite and all 224 conformance programs green, and they are kept only because four consumers read those tables per NAME and nothing but their current internals stops each from picking one | -| `test_lexical_fn_scope_1299.py` | 56 | 1,535 | #1299 — codegen's bare-call ownership table must be the CALL SITE's lexical scope. The #1284 predicate is one rule over two tables, and codegen's was `set(_fn_sigs)`: every symbol the whole compilation absorbed, including names the compiling body cannot see, so a bare `get(())` the checker resolved to a `State` operation was lowered as a call to some other declaration. Four routes, all check-green — an imported module's **private** `get`, a **public** one a selective import excludes, a `where` helper of a **`forall` parent** (which keeps a bare key beside its clone-qualified one where a non-generic parent's does not), and the ability operation `show`, which E151 does not reserve and which reaches the same table through the INTRINSIC gate rather than the op one. Where the widths agreed the module loaded and answered the invisible declaration's value (7007 for the cell's 42007); where they differed it failed to load; the generic-`where` route is always loud (`unknown func $get`). Every expected value is the checker's, PROVEN by a type oracle rather than assumed — the invisible `get` returns `@Bool` while the caller returns `@Int` from it and checks green — and each route carries a rename control. The visibility matrix (public/private × in-filter/excluded/wildcard × shadowed/unshadowed × direct/transitive) asserts the verify verdict and the runtime value together per cell. The two directions are pinned at once: the sibling loses the name, the generic TEMPLATE keeps it (asserted on the emitted instruction stream, since monomorphization supersedes the template and a value assertion would be green either way), and a lifted closure — compiled through its own `WasmContext` — inherits its parent's scope. Four table invariants sit beside them: the scoped set is a subset of the registry, every `$`-bearing key stays in it, prelude names stay in it, and every emission door supplies a declaration its own helpers | +| `test_module_generic_collision_1281.py` | 20 | 778 | #1281 — E608 must not refuse two modules' PROVABLY DISTINCT generics. A generic emits nothing under its bare name, and since #1274 its clones live in a namespace chosen per OWNER, so the diamond (`base` public, `mid1` private, both named `gen`) cannot overwrite anything — and was refused outright, with `vera verify` returning rc=0 beside the refusal. Each door now answers its own module's generic (555 + 111) against the standalone oracles, and the emitted module carries `mod$…$mid1$gen$Bool` and `mod$…$base$gen$Bool` with nothing in the entry's bare clone namespace. The relaxation is gated on three conditions, each with its own cell: both declarations are top-level generics (a generic beside a non-generic keeps the refusal), at most one owns the bare name (asked of the predicate directly, since end to end the ambiguity gate catches that shape first), and no namespace can name both — a module importing two dependencies that each export `gen` resolves its own bare call to one of them, and neither spec §8.5 nor the checker says which: the checker's pick is a set-iteration artefact, and over eight runs of one unchanged program the type oracle accepted four times and was rejected four times (stable under a fixed `PYTHONHASHSEED`, varying with the seed, and identical whichever textual order the imports are written in), while codegen's reroute map is positional last-wins. Compiling the shape would pick a body by coin-flip, so it keeps its loud refusal (issue 1304). A namespace that declares its own `gen` is not ambiguous however many dependencies export one (§8.5.2). The registration half — a qualified-only generic contributing no bare `_fn_sigs` or `_fn_ret_type_exprs` entry — is pinned by two STRUCTURAL cells, one per table, and its docstring says why: with #1299's scope narrowing in place both withholdings are defence in depth, reverting them leaves every suite and all 226 conformance programs green, and they are kept only because four consumers read those tables per NAME and nothing but their current internals stops each from picking one | +| `test_lexical_fn_scope_1299.py` | 56 | 1,546 | #1299 — codegen's bare-call ownership table must be the CALL SITE's lexical scope. The #1284 predicate is one rule over two tables, and codegen's was `set(_fn_sigs)`: every symbol the whole compilation absorbed, including names the compiling body cannot see, so a bare `get(())` the checker resolved to a `State` operation was lowered as a call to some other declaration. Four routes, all check-green — an imported module's **private** `get`, a **public** one a selective import excludes, a `where` helper of a **`forall` parent** (which keeps a bare key beside its clone-qualified one where a non-generic parent's does not), and the ability operation `show`, which E151 does not reserve and which reaches the same table through the INTRINSIC gate rather than the op one. Where the widths agreed the module loaded and answered the invisible declaration's value (7007 for the cell's 42007); where they differed it failed to load; the generic-`where` route is always loud (`unknown func $get`). Every expected value is the checker's, PROVEN by a type oracle rather than assumed — the invisible `get` returns `@Bool` while the caller returns `@Int` from it and checks green — and each route carries a rename control. The visibility matrix (public/private × in-filter/excluded/wildcard × shadowed/unshadowed × direct/transitive) asserts the verify verdict and the runtime value together per cell. The two directions are pinned at once: the sibling loses the name, the generic TEMPLATE keeps it (asserted on the emitted instruction stream, since monomorphization supersedes the template and a value assertion would be green either way), and a lifted closure — compiled through its own `WasmContext` — inherits its parent's scope. Four table invariants sit beside them: the scoped set is a subset of the registry, every `$`-bearing key stays in it, prelude names stay in it, and every emission door supplies a declaration its own helpers | | `test_phantom_generic_instances_1271.py` | 14 | 358 | #1271 — discovery inside a still-generic scope must not instantiate a callee at an ENCLOSING scope's type VARIABLE. `pick(@U.1, @U.0)` inside `forall fn helper` bound `pick`'s variable to the NAME `U`, so a `pick$U` clone was emitted whose parameter has no WASM type and which the compilability pass then skipped with a loud `[E604]` — the noise that kept #1223's shapes out of the conformance suite. Drives the four #1223 fixtures plus a mutual-recursion shape (two sibling generic helpers under a generic parent, whose phantoms include one arriving through a callee's declared RETURN type, `leaf$W`), asserting on ONE compile that no clone is keyed by a type variable, that no E602/E604/E605 skip is emitted, AND that the genuinely concrete clone is still there — the third assertion being what separates the filter from an over-filter that would take the real instantiation with it. Plus the **primitive-spelled binder** matrix (`forall`, ``, ``, ``), each row instantiating a sibling at exactly the type its binder is spelled like — a shared `idw(5)` would have let every row but `Int` pass for free — asserted on the clone set AND on the program still running; with the `Q`-binder control that keeps a genuine type variable filtered, so the fix cannot degenerate into "never filter". That control CREATES a live phantom candidate — a generic helper under a generic parent, handing its callee an argument typed by its own binder — because a control that merely fails to create one holds under any filter including none; mutation-checked by disabling the filter, which turns it red | | `test_handle_exn_divergent_result_1276.py` | 10 | 391 | #1276 — a `handle[Exn]` whose clause body AND handled body both diverge emitted a result-LESS `block` into a result-expecting context: check-green, verify-green, rejected at load with `type mismatch: expected i64 but nothing on stack`. Four divergent shapes (the issue's Int rethrow, the `Byte` payload spelling #1269 unmasked, a three-deep rethrow chain, and a clause diverging through both arms of an `if`), each asserted on valid WASM AND on the observable — the OUTER handler's clause value, 1000. Paired with the Unit TWIN, which infers `None` for the same reason but DOES complete: it must keep running and its WAT must contain no `unreachable` at all. The pairing is the point — `result_wt is None` means two things wanting opposite lowerings, and a fix that terminated both would trap a program that runs. The MIRROR family covers a clause that throws on one path and COMPLETES on the other (`if` and `match` spellings), where the inference read only the `then` branch / arm 0, answered `None`, and left the completing path's value stranded in a result-less block; the `if` case appears twice with different thrown values so both the throwing and the completing path are exercised from one inference | | `test_generic_under_generic_callees_1223.py` | 8 | 298 | #1223 — a generic `where`-helper under a GENERIC parent instantiates its own generic callees. The helper is monomorphized only during clone hoisting, outside the worklist that rescans every clone it emits, so a top-level generic called from the helper body was discovered only in its still-generic spelling (`pick$U`, binding the enclosing type variable's NAME) while the rewrite called `pick$Bool` — E602 skip, E620 drop of the parent and of `main`, "No exported functions" from a check-clean, verify-clean program. Four shapes — a user generic, the prelude twin (`option_unwrap_or`), two levels of generic nesting where the INNER helper is the caller, and the non-generic-parent control that compiled before the fix and must keep compiling (it is what proves the trigger is the generic ancestor rather than the nested helper) — each asserted on no E602/E620, the checker's run value (the helper's argument order is non-commutative, so a miswiring gives 7 instead of 3), and a REGISTERED-vs-RESOLVED differential. That differential captures the emitted mono-decl names rather than `_emitted_instances` (whose generic-under-generic entries are keyed by the concrete-free lexical chain, not by the per-clone emission name the rewrite calls) and captures the rewrite side on `_resolve_generic_call` rather than from the WAT, because a desync skips the calling function and removes the dangling `call` along with it. The verifier's half of the pair is pinned in `test_monomorphize_differential.py`'s inline corpus, not here | @@ -406,7 +406,7 @@ The manifest is the machine-readable feature inventory — agents can query it t ### Running the conformance suite ```bash -# Via pytest (parametrized — 1,080 tests) +# Via pytest (parametrized — 1,130 tests) pytest tests/test_conformance.py -v # Via standalone script (used in CI and pre-commit) diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 146fa583..25274fc4 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -3256,7 +3256,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 10,990 tests, including a 226-program conformance suite +- 10,991 tests, including a 226-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/vera/README.md b/vera/README.md index 7ff1b80a..2d79bf4c 100644 --- a/vera/README.md +++ b/vera/README.md @@ -752,7 +752,7 @@ The `ERROR_CODES` dict in `errors.py` maps every code to a short description (15 ## Test Suite -Testing spans a **pytest suite** of 10,990 tests across 166 files — compiler-internals unit tests plus a **conformance suite** (226 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. +Testing spans a **pytest suite** of 10,991 tests across 166 files — compiler-internals unit tests plus a **conformance suite** (226 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. See **[TESTING.md](../TESTING.md)** for the comprehensive testing reference -- test file table, conformance suite details, compiler code coverage, language feature coverage, helper conventions, validation scripts, CI pipeline, and guidelines for adding tests. From 3ad824bf59c5816a6b7682e9fec25d681f42695d Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 11:00:21 +0100 Subject: [PATCH 24/69] Type the #1299 monkeypatch stand-ins and retire their suppressions CodeRabbit flagged the `wrap`/`probe` pair for carrying `no-untyped-def` suppressions where explicit annotations would do. Two of the premises around that finding did not survive measurement, so the fix is a little different from the suggested one. The suppressions are not vestigial. Neither gate type-checks tests -- the pre-commit hook and the CI job both run `mypy vera/`, and `[tool.mypy]` declares no `files` key -- but pointing mypy at the file directly reports three real `no-untyped-def` errors the moment the comments come off. Annotating is also not free. A monkeypatch stand-in draws two independent codes: `method-assign` for assigning to a method at all, and `assignment` for a stand-in whose type does not match the target. An unannotated stand-in is implicitly `Any`, so only the first fires; naming the type makes the mismatch visible and the second appears too. Each annotation therefore has to be paired with the code it exposes, at its own assignment line and only there -- `strict = true` implies `warn_unused_ignores`, which rejects that second code on the `finally` lines, where what is restored is the exact original. `probe` keeps `Callable[..., Any]` rather than a precise signature: it stands in for three different methods whose parameters it never inspects, so "forwards whatever it was given" is the honest type. A `ParamSpec` would say the same thing at more cost and has no precedent in the tree. Pointed at the file, mypy goes from five errors to none and no `no-untyped-def` suppression remains. `mypy vera/` and the file's 56 tests are unchanged; the diff is annotations and comments only. Skip-changelog: tests-only typing cleanup, no behaviour change. Co-Authored-By: Claude --- TESTING.md | 2 +- tests/test_lexical_fn_scope_1299.py | 28 ++++++++++++++++++---------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/TESTING.md b/TESTING.md index 21c297e4..cb284df6 100644 --- a/TESTING.md +++ b/TESTING.md @@ -83,7 +83,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_clone_body_declaring_module_1241_1243.py` | 5 | 344 | #1241 + #1243 — an imported generic's clone body resolves its bare calls in the DECLARING module, on both sides. The verifier's lexical lookup fell through to the importer's registry (`_declaring_module_scope` swapped the naming env, source and file but not the function registry) and codegen's clone-emission door was the one door that did not thread the module's intra-rename map, so `glib`'s `gen` called the importer's `need`. The two halves are one routing rule, and the tests are written so neither passes alone: each case asserts the `vera verify` verdict AND the `vera run` value together, so the verifier half alone (which makes verify clean while the compiled program still traps on the postcondition it just proved — the measured false Tier-1) fails the same test the codegen half alone (right value, verify still refusing) fails. Every expected value comes from the module verified and run STANDALONE, never from what the importer produces. Shapes: a private direct callee, a two-hop private chain, the type-discriminating pair (`@Int` vs `@Bool` — check-green source that emitted invalid WASM), and the unshadowed-callee control that was correct before and after, which is what pins the defect to the SHADOWED name rather than to cross-module calls in general | | `test_module_generic_namespace_1274.py` | 24 | 1,015 | #1274 — a module generic that does not own the importer's bare name is reached under `mod$$name`. Pre-fix only PRIVATE module generics were routed that way (#1000), so a PUBLIC one collided with the importer's same-named generic in the clone-name space: both files' `gen2` mangled to one `gen2$Bool`, one overwrote the other, and the module's own body ran the importer's — a **false Tier-1** (`check`/`verify` clean, the module's proved `ensures` violated at run: 999 where the declaring module answers 111). The full visibility matrix (module generic × importer generic), the import-filter dimension (out-of-filter, in-filter, wildcard), the unshadowed-out-of-filter cell that assembled to `unknown func $gen2`, and a type-discriminating shape whose two clones have different WAT result types. Each cell asserts the verify VERDICT and the runtime VALUE together in one test — a clean verify beside a violated postcondition IS the bug, so splitting them across sibling tests would let it hide — against the standalone library as oracle, and re-checks that the importer's own generic still answers its own value. The per-module both-sides differential lives in `test_monomorphize_differential.py`. Two further families joined after the adversarial round: the module→module **hop** — a module's bare call to a DIFFERENT module's qualified-only generic, which the per-module classification never rerouted, in both a LOUD spelling (a contract pins the answer, so a captured call traps) and a SILENT one (every contract admits both answers, so only the value distinguishes them) plus the two-hop shape where the entry never imports the declaring module at all; and the **shared-input** pair, which pins that the two sides compute the importer's occupied bare names identically — codegen reads them after Pass 0's helper renames, the verifier from the pre-transform AST, and a non-generic `where`-helper named `gen2` made the same imported generic bare-name-owning on one side and qualified-only on the other. The idempotence of that derivation is asserted directly across BOTH Pass-0 transforms and their composition — over a fixture carrying every helper shape it distinguishes (non-generic under non-generic, under a generic parent, under a generic helper, and a generic helper), since a fixture missing one would let a partial assertion look total — with each shape's membership pinned individually beside it, because idempotence alone would hold for a derivation that answered the same WRONG set every time. Two more families close the visibility dimension: the **transitive** one, driven through the production `ModuleResolver` (a hand-built `ResolvedModule` defaults `direct=True` and would never reach the path), asserting that a module reached only transitively has ALL its generics qualified-only — the entry's namespace does not hold them at all; and the **user-written qualified call** (`deep::gen(true)` where the importer declares its own `gen`), which must key its instantiation to the module's declaration rather than to whoever owns the bare name | | `test_module_generic_collision_1281.py` | 20 | 778 | #1281 — E608 must not refuse two modules' PROVABLY DISTINCT generics. A generic emits nothing under its bare name, and since #1274 its clones live in a namespace chosen per OWNER, so the diamond (`base` public, `mid1` private, both named `gen`) cannot overwrite anything — and was refused outright, with `vera verify` returning rc=0 beside the refusal. Each door now answers its own module's generic (555 + 111) against the standalone oracles, and the emitted module carries `mod$…$mid1$gen$Bool` and `mod$…$base$gen$Bool` with nothing in the entry's bare clone namespace. The relaxation is gated on three conditions, each with its own cell: both declarations are top-level generics (a generic beside a non-generic keeps the refusal), at most one owns the bare name (asked of the predicate directly, since end to end the ambiguity gate catches that shape first), and no namespace can name both — a module importing two dependencies that each export `gen` resolves its own bare call to one of them, and neither spec §8.5 nor the checker says which: the checker's pick is a set-iteration artefact, and over eight runs of one unchanged program the type oracle accepted four times and was rejected four times (stable under a fixed `PYTHONHASHSEED`, varying with the seed, and identical whichever textual order the imports are written in), while codegen's reroute map is positional last-wins. Compiling the shape would pick a body by coin-flip, so it keeps its loud refusal (issue 1304). A namespace that declares its own `gen` is not ambiguous however many dependencies export one (§8.5.2). The registration half — a qualified-only generic contributing no bare `_fn_sigs` or `_fn_ret_type_exprs` entry — is pinned by two STRUCTURAL cells, one per table, and its docstring says why: with #1299's scope narrowing in place both withholdings are defence in depth, reverting them leaves every suite and all 226 conformance programs green, and they are kept only because four consumers read those tables per NAME and nothing but their current internals stops each from picking one | -| `test_lexical_fn_scope_1299.py` | 56 | 1,546 | #1299 — codegen's bare-call ownership table must be the CALL SITE's lexical scope. The #1284 predicate is one rule over two tables, and codegen's was `set(_fn_sigs)`: every symbol the whole compilation absorbed, including names the compiling body cannot see, so a bare `get(())` the checker resolved to a `State` operation was lowered as a call to some other declaration. Four routes, all check-green — an imported module's **private** `get`, a **public** one a selective import excludes, a `where` helper of a **`forall` parent** (which keeps a bare key beside its clone-qualified one where a non-generic parent's does not), and the ability operation `show`, which E151 does not reserve and which reaches the same table through the INTRINSIC gate rather than the op one. Where the widths agreed the module loaded and answered the invisible declaration's value (7007 for the cell's 42007); where they differed it failed to load; the generic-`where` route is always loud (`unknown func $get`). Every expected value is the checker's, PROVEN by a type oracle rather than assumed — the invisible `get` returns `@Bool` while the caller returns `@Int` from it and checks green — and each route carries a rename control. The visibility matrix (public/private × in-filter/excluded/wildcard × shadowed/unshadowed × direct/transitive) asserts the verify verdict and the runtime value together per cell. The two directions are pinned at once: the sibling loses the name, the generic TEMPLATE keeps it (asserted on the emitted instruction stream, since monomorphization supersedes the template and a value assertion would be green either way), and a lifted closure — compiled through its own `WasmContext` — inherits its parent's scope. Four table invariants sit beside them: the scoped set is a subset of the registry, every `$`-bearing key stays in it, prelude names stay in it, and every emission door supplies a declaration its own helpers | +| `test_lexical_fn_scope_1299.py` | 56 | 1,554 | #1299 — codegen's bare-call ownership table must be the CALL SITE's lexical scope. The #1284 predicate is one rule over two tables, and codegen's was `set(_fn_sigs)`: every symbol the whole compilation absorbed, including names the compiling body cannot see, so a bare `get(())` the checker resolved to a `State` operation was lowered as a call to some other declaration. Four routes, all check-green — an imported module's **private** `get`, a **public** one a selective import excludes, a `where` helper of a **`forall` parent** (which keeps a bare key beside its clone-qualified one where a non-generic parent's does not), and the ability operation `show`, which E151 does not reserve and which reaches the same table through the INTRINSIC gate rather than the op one. Where the widths agreed the module loaded and answered the invisible declaration's value (7007 for the cell's 42007); where they differed it failed to load; the generic-`where` route is always loud (`unknown func $get`). Every expected value is the checker's, PROVEN by a type oracle rather than assumed — the invisible `get` returns `@Bool` while the caller returns `@Int` from it and checks green — and each route carries a rename control. The visibility matrix (public/private × in-filter/excluded/wildcard × shadowed/unshadowed × direct/transitive) asserts the verify verdict and the runtime value together per cell. The two directions are pinned at once: the sibling loses the name, the generic TEMPLATE keeps it (asserted on the emitted instruction stream, since monomorphization supersedes the template and a value assertion would be green either way), and a lifted closure — compiled through its own `WasmContext` — inherits its parent's scope. Four table invariants sit beside them: the scoped set is a subset of the registry, every `$`-bearing key stays in it, prelude names stay in it, and every emission door supplies a declaration its own helpers | | `test_phantom_generic_instances_1271.py` | 14 | 358 | #1271 — discovery inside a still-generic scope must not instantiate a callee at an ENCLOSING scope's type VARIABLE. `pick(@U.1, @U.0)` inside `forall fn helper` bound `pick`'s variable to the NAME `U`, so a `pick$U` clone was emitted whose parameter has no WASM type and which the compilability pass then skipped with a loud `[E604]` — the noise that kept #1223's shapes out of the conformance suite. Drives the four #1223 fixtures plus a mutual-recursion shape (two sibling generic helpers under a generic parent, whose phantoms include one arriving through a callee's declared RETURN type, `leaf$W`), asserting on ONE compile that no clone is keyed by a type variable, that no E602/E604/E605 skip is emitted, AND that the genuinely concrete clone is still there — the third assertion being what separates the filter from an over-filter that would take the real instantiation with it. Plus the **primitive-spelled binder** matrix (`forall`, ``, ``, ``), each row instantiating a sibling at exactly the type its binder is spelled like — a shared `idw(5)` would have let every row but `Int` pass for free — asserted on the clone set AND on the program still running; with the `Q`-binder control that keeps a genuine type variable filtered, so the fix cannot degenerate into "never filter". That control CREATES a live phantom candidate — a generic helper under a generic parent, handing its callee an argument typed by its own binder — because a control that merely fails to create one holds under any filter including none; mutation-checked by disabling the filter, which turns it red | | `test_handle_exn_divergent_result_1276.py` | 10 | 391 | #1276 — a `handle[Exn]` whose clause body AND handled body both diverge emitted a result-LESS `block` into a result-expecting context: check-green, verify-green, rejected at load with `type mismatch: expected i64 but nothing on stack`. Four divergent shapes (the issue's Int rethrow, the `Byte` payload spelling #1269 unmasked, a three-deep rethrow chain, and a clause diverging through both arms of an `if`), each asserted on valid WASM AND on the observable — the OUTER handler's clause value, 1000. Paired with the Unit TWIN, which infers `None` for the same reason but DOES complete: it must keep running and its WAT must contain no `unreachable` at all. The pairing is the point — `result_wt is None` means two things wanting opposite lowerings, and a fix that terminated both would trap a program that runs. The MIRROR family covers a clause that throws on one path and COMPLETES on the other (`if` and `match` spellings), where the inference read only the `then` branch / arm 0, answered `None`, and left the completing path's value stranded in a result-less block; the `if` case appears twice with different thrown values so both the throwing and the completing path are exercised from one inference | | `test_generic_under_generic_callees_1223.py` | 8 | 298 | #1223 — a generic `where`-helper under a GENERIC parent instantiates its own generic callees. The helper is monomorphized only during clone hoisting, outside the worklist that rescans every clone it emits, so a top-level generic called from the helper body was discovered only in its still-generic spelling (`pick$U`, binding the enclosing type variable's NAME) while the rewrite called `pick$Bool` — E602 skip, E620 drop of the parent and of `main`, "No exported functions" from a check-clean, verify-clean program. Four shapes — a user generic, the prelude twin (`option_unwrap_or`), two levels of generic nesting where the INNER helper is the caller, and the non-generic-parent control that compiled before the fix and must keep compiling (it is what proves the trigger is the generic ancestor rather than the nested helper) — each asserted on no E602/E620, the checker's run value (the helper's argument order is non-commutative, so a miswiring gives 7 instead of 3), and a REGISTERED-vs-RESOLVED differential. That differential captures the emitted mono-decl names rather than `_emitted_instances` (whose generic-under-generic entries are keyed by the concrete-free lexical chain, not by the per-clone emission name the rewrite calls) and captures the rewrite side on `_resolve_generic_call` rather than from the WAT, because a desync skips the calling function and removes the dangling `call` along with it. The verifier's half of the pair is pinned in `test_monomorphize_differential.py`'s inline corpus, not here | diff --git a/tests/test_lexical_fn_scope_1299.py b/tests/test_lexical_fn_scope_1299.py index e2fd7ab4..9d24259f 100644 --- a/tests/test_lexical_fn_scope_1299.py +++ b/tests/test_lexical_fn_scope_1299.py @@ -46,8 +46,9 @@ import os import traceback +from collections.abc import Callable from pathlib import Path -from typing import ClassVar +from typing import Any, ClassVar import pytest @@ -778,7 +779,7 @@ def recording( seen.append((set(out), set(gen._fn_sigs))) return out - CodeGenerator._scoped_fn_names = recording # type: ignore[method-assign] + CodeGenerator._scoped_fn_names = recording # type: ignore[method-assign,assignment] try: build_multi_module( tmp_path, @@ -819,7 +820,7 @@ def recording( ) return out - CodeGenerator._scoped_fn_names = recording # type: ignore[method-assign] + CodeGenerator._scoped_fn_names = recording # type: ignore[method-assign,assignment] try: build_multi_module( tmp_path, @@ -881,7 +882,7 @@ def recording( seen.append((set(out), set(gen._fn_sigs))) return out - CodeGenerator._scoped_fn_names = recording # type: ignore[method-assign] + CodeGenerator._scoped_fn_names = recording # type: ignore[method-assign,assignment] try: build_multi_module( tmp_path, {"main.vera": _generic_where("get")}, @@ -924,7 +925,9 @@ def test_every_emission_door_supplies_the_decl_its_own_helpers( seen: list[tuple[str, frozenset[str], frozenset[str]]] = [] original = CodeGenerator._compile_fn_tracked - def recording(gen: CodeGenerator, decl, **kw): # type: ignore[no-untyped-def] + def recording( + gen: CodeGenerator, decl: ast.FnDecl, **kw: Any + ) -> str | None: seen.append(( decl.name, frozenset( @@ -934,7 +937,7 @@ def recording(gen: CodeGenerator, decl, **kw): # type: ignore[no-untyped-def] )) return original(gen, decl, **kw) - CodeGenerator._compile_fn_tracked = recording # type: ignore[method-assign] + CodeGenerator._compile_fn_tracked = recording # type: ignore[method-assign,assignment] try: build_multi_module( tmp_path, @@ -1351,11 +1354,11 @@ def test_a_walked_declarations_own_helpers_join_its_scope(self) -> None: seen: list[bool] = [] original = Monomorphizer._collect_calls_in_node_scoped - def recording(inner, fn, *a): # type: ignore[no-untyped-def] + def recording(inner: Monomorphizer, fn: ast.FnDecl, *a: Any) -> None: seen.append(inner._bare_call_is_user_fn("get")) return original(inner, fn, *a) - Monomorphizer._collect_calls_in_node_scoped = recording # type: ignore[method-assign] + Monomorphizer._collect_calls_in_node_scoped = recording # type: ignore[method-assign,assignment] try: with mono.namespace_scope(None): assert not mono._bare_call_is_user_fn("get") @@ -1516,8 +1519,13 @@ def test_no_discovery_walk_runs_without_a_namespace_scope( ) } - def wrap(original): # type: ignore[no-untyped-def] - def probe(inner, *a, **kw): # type: ignore[no-untyped-def] + # `Callable[..., Any]`, not a precise signature: `probe` stands in + # for three DIFFERENT methods whose parameters it never inspects, so + # the honest type is "forwards whatever it was given". A ParamSpec + # would say the same thing at more cost, and nothing in this repo + # uses one. + def wrap(original: Callable[..., Any]) -> Callable[..., Any]: + def probe(inner: Monomorphizer, *a: Any, **kw: Any) -> Any: if (inner.ctx.namespace_fn_names is not None and inner._scope_fn_names is None): site = "?" From 1ffd5409e4247322991d2ceffe795f60dc102999 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 11:18:58 +0100 Subject: [PATCH 25/69] Correct the dual-target conformance count, and label what it covers TESTING.md described the `test_wasi_target.py` differential as running "all 156 run-level conformance programs under both targets". The manifest holds 166 run-level entries, so the figure was ten behind -- rebase-carried prose lagging the union of several branches, this one included. Correcting the number alone would still have overstated the guarantee. `TestDualTargetConformance` parametrizes over every run-level program, but two routes skip: an op in `_NONDETERMINISTIC_OPS` reached through the compiled WAT's `vera` imports, and a `ValueError` out of `execute_wasi_p2`. Measured, that is 116 programs actually dual-tested and 50 skipped -- 43 importing a host family outside `IO`/`Random`, 6 without a public zero-argument `main`, and 1 calling a nondeterministic op. A reader sizing the dual-target guarantee off the old sentence would have been out by a factor of about 1.4, in the flattering direction. The row now carries both numbers with the skip breakdown. The excluded set is identified by the three properties that define it rather than by a list of 50 filenames, which would be stale by the next conformance program. Swept the rest of the row while there: the 267/2,142 test and line counts are gated and current, and the 500-arg argv stress, the 64 KiB arena cap, and the 32-slot dispatch table each check out against the tests and emitted WAT that assert them. The conformance-suite total of 226 below it matches the manifest. Skip-changelog: docs-only count correction, no behaviour change. Co-Authored-By: Claude --- TESTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TESTING.md b/TESTING.md index cb284df6..85457f8b 100644 --- a/TESTING.md +++ b/TESTING.md @@ -220,7 +220,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_doc_builtin_shadowing.py` | 8 | 107 | `check_doc_builtin_shadowing.py` gate ([#819](https://github.com/aallan/vera/issues/819)): reject-set membership (opaque built-ins in, overridable combinators out), top-level + `where`-block `fn ` definitions flagged, non-built-in / overridable / prose-mention ignored, and the shipped docs are currently clean | | `test_runtime_traps.py` | 75 | 2,877 | Runtime trap categorisation (#516 Stage 1), out-of-bounds host-read bounds check (#1145), stdout/stderr-on-trap preservation (#522), `IO.print` live tee (#543), and trap source backtrace (#516 Stage 2): `_classify_trap` per-`kind` mapping (`divide_by_zero`/`out_of_bounds`/`stack_exhausted`/`unreachable`/`overflow`/`contract_violation`/`unknown`), `WasmTrapError` shape + `RuntimeError` substitutability, end-to-end `cmd_run` text + JSON envelopes including `trap_kind`, captured `stdout`, captured `stderr`, JSON-mode "no stderr leak" invariant, cross-stream code-order regression using merged `redirect_stdout`/`redirect_stderr`, the v0.0.123 tee suite (live streaming, write-count + order preservation, JSON-mode tee suppression, trap preservation invariant under tee, per-write flush count, default-execute silence), and the v0.0.124 source-mapping suite — `_resolve_trap_frames` unit tests covering user-fn / built-in / built-in-prefix / monomorphized base-name fallback / unknown-name / no-frames-attribute / leaf-first ordering preservation; end-to-end `cmd_run` text-mode + JSON-mode backtrace including the **leaf-first** ordering invariant; contract-violation backtrace in both text and JSON modes; direct `execute()` `WasmTrapError.frames` attachment; **suppression marker** for collapsed leading runtime-helper frames (mocked `vera.codegen.execute` with synthetic `is_builtin=True` leaf frames so the collapse logic is testable deterministically); source-map population for top-level fns + lifted closures (with span-value assertion against the closure literal's exact line range); and the no-builtin-leakage regression that pins built-in helpers (`alloc` / `gc_collect` / `contract_fail`) NOT being registered in `fn_source_map`; plus the v0.0.125 Stage 3 suite (`#547`) — text-mode `Fix:` block surfacing with position-ordering invariant (Fix appears after the source backtrace), text-mode block suppression for `contract_violation` (no empty header noise), JSON-mode `fix` field always-present (schema stability) including the empty-string case, `_TRAP_FIX_PARAGRAPHS` table-completeness assertion (every kind in the taxonomy has a Fix paragraph entry), and the column-wrap invariant (~76 chars max per line, two-space indent under the `Fix:` heading); plus the UTF-8 hardening suite **`TestHostPrintInvalidUtf8589`** (`#589` / `#592`) — after `#592` centralised the `errors="replace"` invariant into the single `vera.runtime.text.safe_utf8_decode` helper — reached only through a shared `_slice_and_decode` helper (`vera/runtime/heap.py`) that the three WASM-memory string readers (`_read_wasm_string` and `_read_string_export` there, and `vera/wasm/markdown.py::_read_string`) delegate to, with the `host_print` / `host_stderr` / `host_contract_fail` host imports and the String-return extractor in `execute()` routing through those readers rather than decoding inline: one helper unit test pinning the invariant once (invalid bytes → U+FFFD, valid + empty pass through), three wire-real end-to-end tests that drive the **production** readers (`_read_wasm_string` / markdown `_read_string` behind a synthetic-WAT `probe` host import; `_read_string_export` against a real exported memory, also covering its out-of-bounds → `None` pointer-fallback) over a region seeded with invalid UTF-8 — so a strict-decode regression surfaces as a `UnicodeDecodeError` escaping wasmtime's trampoline, and the host imports / extractor are transitively covered — and one synthetic-WAT end-to-end test that imports `vera.print` and calls it with raw invalid UTF-8 bytes to pin the wasmtime-trampoline fact independently (a Python `UnicodeDecodeError` inside a host import escapes as a "python exception" cause iff the host decode is strict); the six pre-`#592` structural source-grep assertions were retired by the centralisation; plus the Ctrl-C-during-host-import suite **`TestHostSleepKeyboardInterrupt`** ([#595](https://github.com/aallan/vera/issues/595) / [#599](https://github.com/aallan/vera/issues/599)) — after the v0.0.160 relocation to a single `except KeyboardInterrupt` handler in `execute()` (enabled by `wasmtime>=45.0.0`'s `except BaseException` trampoline fix): one structural assertion that the four per-host-import `raise _VeraExit(130)` guards are gone and the centralized handler maps to `exit_code=130`, plus four end-to-end tests that compile real Vera programs calling `IO.sleep(...)`, `IO.read_char(())`, a mocked fused `await` ([#841](https://github.com/aallan/vera/issues/841) — `Future.result()` patched to interrupt), and a live in-flight fused `await` (no mocking — `_thread.interrupt_main()` fired only once the server confirms the request arrived, handler then released so the executor teardown has a real worker to wait out; post-[#848](https://github.com/aallan/vera/issues/848) the progress print precedes the `async(...)`, so program order makes its stdout assertion deterministic), raise `KeyboardInterrupt` from inside the blocking call, and assert the program exits with `ExecuteResult.exit_code == 130` (pre-interrupt stdout preserved) instead of a raw Python traceback escaping wasmtime's trampoline | | `test_serve.py` | 8 | 189 | #305 `vera serve` driver end-to-end: GET/POST echo round-trips (method/path/headers/body cross the host↔guest boundary via `build_request_adt` / `decode_response_adt`), handler status propagation, runtime contract violation → 500 with `trap_kind` JSON, `State` isolation across requests (instance-per-request pinned), and clean `make_server` validation errors (missing / wrong-signature `handle`), and an eager-GC round-trip pinning the Request builder's shadow-rooting; all on ephemeral ports | -| `test_wasi_target.py` | 267 | 2,142 | #237 WASI Preview 2 target (spec chapter 13): component emission validated live against the real wasmtime host — parse (`Component(engine, wat)`), instantiate (`Linker.add_wasip2()` + `WasiConfig`), and execute (stdout/stderr capture, env, argv incl. a 500-arg GC-pressure stress and a >64 KiB arena-cap trap, preopen file round-trips + errno mapping, stdin incl. UTF-8 multibyte, clocks, random bounds, exit, contract-violation text on WASI stderr, overflow); the family gate (clean diagnostic naming unsupported families, never a silent fallback); the core-emission pin (default `--target wasm` WAT untouched); `cmd_compile`/`cmd_run --target wasi-p2` CLI integration (binary component artifact, `--wat` component text, JSON envelopes, trap-kind classification through the component boundary, exit-code 0/1 degradation, `--fn` rejection); the `execute_wasi_p2` host runner (env passthrough, argv, stderr capture, String-main `wasi:cli/run` fallback); the **dual-target conformance differential** (all 156 run-level conformance programs under both targets, byte-identical stdout/stderr required; nondeterministic-op and family-gated programs skip loudly); a stock-`wasmtime`-CLI smoke test (skips when the CLI is not installed); and the Stage-D **server world** (`world="server"`): incoming-handler emission pins (adapter lift, 32-slot dispatch table, @0.2.0 version pin, no `wasi:cli/run`), #305 handler validation + server family-gate diagnostics (rejected IO ops, non-String map instantiations, unsupported families), the cli-world pin (default emission carries no server machinery), Request/Response layout tripwires, and a stock-`wasmtime serve` smoke battery (host-vs-served differential over a method/path/header/body matrix incl. duplicate-header later-wins, in-guest map-op order parity, `IO.print` console routing, trap→500 with symbolized backtrace + violation text, graceful 500s for forbidden headers and out-of-range status, a 1 MiB GC-stress echo, and an eager-GC shadow-push mutation validation; skips when the CLI is not installed) | +| `test_wasi_target.py` | 267 | 2,142 | #237 WASI Preview 2 target (spec chapter 13): component emission validated live against the real wasmtime host — parse (`Component(engine, wat)`), instantiate (`Linker.add_wasip2()` + `WasiConfig`), and execute (stdout/stderr capture, env, argv incl. a 500-arg GC-pressure stress and a >64 KiB arena-cap trap, preopen file round-trips + errno mapping, stdin incl. UTF-8 multibyte, clocks, random bounds, exit, contract-violation text on WASI stderr, overflow); the family gate (clean diagnostic naming unsupported families, never a silent fallback); the core-emission pin (default `--target wasm` WAT untouched); `cmd_compile`/`cmd_run --target wasi-p2` CLI integration (binary component artifact, `--wat` component text, JSON envelopes, trap-kind classification through the component boundary, exit-code 0/1 degradation, `--fn` rejection); the `execute_wasi_p2` host runner (env passthrough, argv, stderr capture, String-main `wasi:cli/run` fallback); the **dual-target conformance differential** (all 166 run-level conformance programs driven under both targets, byte-identical stdout/stderr required — 116 are dual-tested and 50 skip *loudly* rather than passing silently: 43 whose compiled WAT imports a host family outside `IO`/`Random` (`state`, `map`, `json`, `set`, `decimal`, `html`, `md`, `regex`, `db`), 6 with no public zero-argument `main`, and 1 calling a nondeterministic op. The excluded set is defined by those three properties rather than by a filename list, so it stays accurate as programs are added); a stock-`wasmtime`-CLI smoke test (skips when the CLI is not installed); and the Stage-D **server world** (`world="server"`): incoming-handler emission pins (adapter lift, 32-slot dispatch table, @0.2.0 version pin, no `wasi:cli/run`), #305 handler validation + server family-gate diagnostics (rejected IO ops, non-String map instantiations, unsupported families), the cli-world pin (default emission carries no server machinery), Request/Response layout tripwires, and a stock-`wasmtime serve` smoke battery (host-vs-served differential over a method/path/header/body matrix incl. duplicate-header later-wins, in-guest map-op order parity, `IO.print` console routing, trap→500 with symbolized backtrace + violation text, graceful 500s for forbidden headers and out-of-range status, a 1 MiB GC-stress echo, and an eager-GC shadow-push mutation validation; skips when the CLI is not installed) | ## Conformance Suite From 1ab8e98e02ad9b4bfd092c6b3f8089f2e8191fbf Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 11:06:31 +0100 Subject: [PATCH 26/69] Close two declaration-order latents: the prelude stamp, and prelude-ADT contention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are shapes spec §8.4.1 allows and codegen mishandled — the prelude's data types are ordinary public declarations a program may name and shadow, and the reserved namespace is the `Vera` prefix alone (E154). ACTIVE (main-file) namespace. `_prelude_decl_order` is not a namespace: `_module_alias_scope` builds every module's index space as `{**prelude, **module_own}`, so it is the base layer under all of them and its contents are a fact about what `inject_prelude` laid down. A main-file `type Option = Int` is accepted and, being an alias rather than a `data`, does not suppress the prelude's `data Option` — so the guard fired on the prelude stamp, `Option` was left out of the block entirely, and every later prelude declaration shifted one place earlier because the counter never advanced. Inside a module namespace the prelude's `Option` then reached `AliasEnv.data_types` at `_BUILTIN_DECL_INDEX`, below `_PRELUDE_DECL_BASE` and so ordered ahead of every other prelude declaration rather than among them. The prelude write is now unconditional; the active space still takes the main file's stamp, so the shadow keeps winning its own namespace. Pinned as an invariance — the same program with and without the shadowing alias must stamp an identical prelude block. every namespace the prelude's data types unconditionally, and the two halves of that gap failed differently. `_adt_members_in_scope` recovered global infrastructure by SUBTRACTING what the namespaces declare from the registered layouts, which is sound only while "declared by a namespace" and "global infrastructure" are disjoint; §8.4.1 makes them overlap, so one file's `data Json` removed `Json` from every OTHER namespace's member set. The floor is now stated positively: `prelude_adt_names()` parses the prelude's own data blocks with the same parser `inject_prelude` uses, and a differential holds the two against each other. Scoping the subtraction per namespace instead is refuted by `test_adt_membership_scope_1253`, which it re-opens. And where a module declares one of the prelude's names with a DIFFERENT SHAPE while the prelude is compiling its own, the two contend for the one layout slot and the module's wins: the prelude's ADT was never registered, its combinators hit `unknown constructor`, and every function touching the type was dropped — all of it WARNINGS pointing into ``, so a check-green program compiled with exit 0 to a module missing its exports. That is now E621, an error at the module's declaration in the module's own file, refused by the same Pass-1.9 severity gate E608/E609/E610 use. The rail reads the DECLARATIONS rather than the registered layouts, which is what makes it cover all eight prelude data types: the layout harvest skips a built-in name outright — the throwaway registrar holds `Option`, `Result`, `Ordering` and `UrlParts` for every module whether it declares them or not — so a layout-keyed rail saw `data Json` and never `data Option`, four of the eight §8.4.1 and §11.16 describe. It asks EVERY declaring module rather than the first: contention is a property of each declaration, and a first-wins lookup let a library that restates the prelude's `Ordering` answer for a sibling declaring a different one, so importing the restating module first hid the sibling's contention completely — check-green, exit 0, the caller silently dropped — while the reverse import order caught it. Layout ownership stays first-wins in `_adt_layout_owners`, which answers the declaration-index question; the two questions are separate. Contention itself is decided by SHAPE (same constructors, same order, same field types, type parameters compared positionally): a module that restates the prelude's type shares the one layout, which is measured legal for all eight at the branch point and stays legal here. A name the prelude does not provide stays E609's, and a control pins that. Evidence and its limits. The 8x2 acceptance battery — every prelude ADT name, module-declares-alone and entry-also-uses — is now clean-green or a single E621 in every cell, where the branch point had four cells of E602/E620 cascade and eight zero-exit compiles with a function silently missing. It ships as a parameterized test — including two-declaring-module cells in both import orders — so neither the four-of-eight coverage nor the order-dependence can return silently. `ch08_module_prelude_adt_contention_rejected` pins the refusal at conformance level (the manifest gained an `expected_error_stage` key: a compile-stage negative asserts the program type-checks cleanly and is then refused with the declared code), paired with `ch08_module_prelude_adt_name`, which imports the same module and never names the type. The conformance manifest's new `expected_error_stage` key is admitted by every surface that describes the negative fixtures (TESTING.md, CLAUDE.md, AGENTS.md, CONTRIBUTING.md), which until now all said a negative fails `check`. `ch08_prelude_adt_name_alias` pins that the #1287 shape is accepted, verifies and runs; it cannot distinguish the fix, since that defect is internal to the declaration-index block and inert at emission, and its header says so — the unit cells carry that claim. §8.4.1 and SKILL.md no longer say an entry-file declaration "serves the whole program": it settles the pair it names — entry versus prelude — and not a clash with a module that declares the same name, which is #1312 and is recorded rather than fixed here. The 264 pre-existing corpus programs emit byte-identical WAT and identical check/verify diagnostics. That is mostly no-regression evidence rather than evidence of correctness — no corpus program has two modules declaring one name, so nothing there exercises the every-declarer rule — but it is not blind: `examples/vera/collections.vera` declares `public data Option { None, Some(T) }` and `examples/modules.vera` imports it, so a rail that fired on the name rather than the shape refuses a shipped example — `vera compile` on `examples/modules.vera` returns E621 under that mutation, which `scripts/check_e602_clean.py` catches as a COMPILE_ERROR (`check_examples.py` runs only `check` and `verify`, so it does not). What tests the rest is the battery above and the mutation matrix behind it. Closes #1287 Closes #1277 Co-Authored-By: Claude --- AGENTS.md | 8 +- CHANGELOG.md | 5 + CLAUDE.md | 8 +- CONTRIBUTING.md | 2 +- FAQ.md | 4 +- KNOWN_ISSUES.md | 4 +- SKILL.md | 2 + TESTING.md | 34 +- docs/SKILL.md | 2 + docs/llms-full.txt | 3 + scripts/check_conformance.py | 42 +- spec/08-modules.md | 2 +- spec/11-compilation.md | 4 + ...odule_prelude_adt_contention_rejected.vera | 28 + .../ch08_module_prelude_adt_name.vera | 21 + .../ch08_prelude_adt_name_alias.vera | 41 + tests/conformance/manifest.json | 54 +- tests/conformance/vera/shadowlib.vera | 20 + tests/test_conformance.py | 33 +- tests/test_prelude_adt_namespace_1277.py | 808 ++++++++++++++++++ tests/test_prelude_decl_stamp_1287.py | 215 +++++ vera/README.md | 4 +- vera/_since.py | 1 + vera/codegen/core.py | 268 +++++- vera/codegen/modules.py | 26 + vera/errors.py | 1 + vera/prelude.py | 108 +++ 27 files changed, 1667 insertions(+), 81 deletions(-) create mode 100644 tests/conformance/ch08_module_prelude_adt_contention_rejected.vera create mode 100644 tests/conformance/ch08_module_prelude_adt_name.vera create mode 100644 tests/conformance/ch08_prelude_adt_name_alias.vera create mode 100644 tests/conformance/vera/shadowlib.vera create mode 100644 tests/test_prelude_adt_namespace_1277.py create mode 100644 tests/test_prelude_decl_stamp_1287.py diff --git a/AGENTS.md b/AGENTS.md index c6f3d97c..60d279f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Read `SKILL.md` for the full language reference. It covers syntax, slot referenc ### Conformance programs as reference -The conformance suite in `tests/conformance/` contains 226 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-two negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) instead must *fail* `check` with the E-code in their `expected_error` field. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. +The conformance suite in `tests/conformance/` contains 219 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-three negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) instead must *fail* with the E-code in their `expected_error` field, at the stage their `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. ### Workflow @@ -187,9 +187,9 @@ Each stage is a module with a single public API function (`parse_file`, `transfo pytest tests/ -v # Run all tests (see TESTING.md) pytest tests/test_conformance.py -v # Conformance suite only mypy vera/ # Type-check the compiler -python scripts/check_conformance.py # All 226 conformance programs hold (positives pass; negatives fail with their E-code) +python scripts/check_conformance.py # All 219 conformance programs hold (positives pass; negatives fail with their E-code) python scripts/check_examples.py # All 42 examples must pass -python scripts/check_corpus_canonical.py # All 274 corpus programs in canonical form +python scripts/check_corpus_canonical.py # All 268 corpus programs in canonical form ``` Test helpers follow a pattern: `_check_ok(source)` / `_check_err(source, match)` / `_verify_ok(source)` / `_verify_err(source, match)`. See existing tests for examples. @@ -198,7 +198,7 @@ When implementing a new language feature, write the conformance program *first* ### Invariants -- All 226 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) must *fail* `check` with their `expected_error` E-code +- All 219 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first - All 42 examples in `examples/` must pass `vera check` and `vera verify` - `mypy vera/` must be clean - `pytest tests/ -v` must pass diff --git a/CHANGELOG.md b/CHANGELOG.md index 819d4ca8..b61de39e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). All 258 pre-existing conformance and example programs emit byte-identical WAT, and all three gates now have conformance coverage: reverting any one of them turns the suite red. - **E608 no longer refuses two modules' provably distinct generics** ([#1281](https://github.com/aallan/vera/issues/1281)). The flat-namespace collision rail exists because Pass 2.5 emits every imported function under one WASM name — but a generic emits nothing under its bare name, and since [#1274](https://github.com/aallan/vera/issues/1274) its clones live in a namespace chosen per *owner*: `gen$Bool` for a generic that owns the importer's bare name, `mod$$gen$Bool` for one that does not. A diamond where `base` declares a public `forall fn gen` and `mid1` a private one occupies two different namespaces and was refused outright with `Function 'gen' is defined in both imported module 'mid1' and 'base'`, while `vera verify` returned rc=0 on the same program — a loud verify-vs-compile disagreement. The rail now reads the same ownership classification the clone namespace does, and fires only when the pair really can collide: when either declaration is not a top-level generic (a non-generic *is* emitted under the bare `$name`), when both own the bare name, or when some namespace can name both — a module importing two dependencies that each export `gen` resolves its own bare call to one of them, and neither spec §8.5 nor the checker says which: the checker's pick is a set-iteration artefact, and the identical program accepts on one run and is rejected on the next (measured 4/4 over eight runs, stable under a fixed `PYTHONHASHSEED`, and unchanged by the textual order of the two imports), so that shape keeps its refusal rather than compiling a body chosen by coin-flip ([#1304](https://github.com/aallan/vera/issues/1304)). The registration moved with the message, as defence in depth: a qualified-only generic no longer injects a bare `_fn_sigs` or `_fn_ret_type_exprs` entry at all. Those two registries are read *per name* by consumers the clone classification says nothing about — `MonoContext.fn_names`, the [#1207](https://github.com/aallan/vera/issues/1207) shadow guard, and the WASM call-rewrite's return-type lookup — where first-module-wins would make the answer depend on registration order. What actually closes that shape is the #1299 scope narrowing above, which reaches the same consultors through the call site: reverting both withholdings leaves every suite and all 224 conformance programs green. They are kept, and pinned structurally on the tables they act on with one cell each, because nothing but those four consumers' current internals stops any of them from picking a winner. +- **A module's data type no longer empties a prelude one out of every other namespace, and the shape that silently dropped functions is now an error** ([#1277](https://github.com/aallan/vera/issues/1277)). Codegen keeps ONE flat `_adt_layouts` map while the checker gives every namespace the prelude's data types from the start, and the two halves of that gap failed differently. **Membership**: `_adt_members_in_scope` recovered global infrastructure by SUBTRACTING what the namespaces declare from the registered layouts, which is sound only while "declared by a namespace" and "global infrastructure" are disjoint — and §8.4.1 makes them overlap on purpose, since the prelude's data types are ordinary public declarations a program may name and shadow. So one file's `data Json` removed `Json` from the member set of every OTHER namespace, including the entry program's, while the checker's `TypeEnv` carried it in all of them; measured as a straight disagreement, `Json` a data type in a module's namespace for the checker and not for codegen. The Pass-0.5 built-in snapshot unioned in as a floor could not protect the four demand-injected prelude ADTs, because it is taken before Pass 1.2 injects them — the same asymmetry [#1253](https://github.com/aallan/vera/issues/1253) fixed, one layer down. The floor is now stated positively rather than recovered by elimination: `vera.prelude.prelude_adt_names()` parses the prelude's own data blocks with the same parser `inject_prelude` uses, so a new prelude ADT joins the set by being written, and a differential holds the two against each other. Scoping the subtraction per namespace instead — the other direction the issue left open — is refuted by `tests/test_adt_membership_scope_1253.py`, which it re-opens: a sibling module's ADT would become infrastructure for every namespace but its own. **Contention**: where a MODULE declares one of the prelude's data type names with a DIFFERENT SHAPE and the prelude is also compiling its own, the two contend for the one layout slot and the module's wins. The prelude's ADT was then never registered, its own combinators hit `unknown constructor` (an `[E602]` inside ``), and every user function touching the type was dropped behind an `[E620]` cascade — all of it reported as WARNINGS, so a `vera check`-green program compiled with **exit 0** to a module with a function silently missing from its exports, and nothing named the declaration that caused it. That is now **E621**, an error located at the module's declaration in the module's own file, refused by the same Pass-1.9 severity gate `E608`/`E609`/`E610` use. It covers all eight of the prelude's data types, which required reading the DECLARATIONS rather than the registered layouts: the layout harvest skips a built-in name outright, because the throwaway registrar holds `Option`, `Result`, `Ordering` and `UrlParts` for every module whether it declares them or not, so a layout-keyed rail saw `data Json` and never `data Option`. The two halves differ only in when the prelude is present — the demand-injected four not until the entry program uses them, the always-injected four in every program — so a differently-shaped module `data Ordering` contends unconditionally, which upgrades that shape from an `[E602]`/`[E620]` cascade to one instruction. What decides contention is the two declarations' SHAPES: the same constructors, in the same order (the tag is the position), with the same field types, type parameters compared positionally. A module that restates the prelude's type shares the one layout — measured legal for all eight, and kept legal, which is what stops the rail from becoming the reservation §8.4.1 forbids. That is not a hypothetical: `examples/vera/collections.vera` declares `public data Option { None, Some(T) }` and `examples/modules.vera` imports it, so a rail that fired on the name alone refuses a shipped example — `vera compile` on `examples/modules.vera` returns E621 under that mutation, which `scripts/check_e602_clean.py` catches as a `COMPILE_ERROR` (measured; `check_examples.py` does not, running only `check` and `verify`). The rail's first form refused four of the synthetic restatements. Every declaring module is asked, not the first: a library that restates the prelude's `Ordering` otherwise answered for a sibling declaring a different one, so with the restating module imported first the sibling's contention went unseen — check-green, exit 0, the caller silently dropped — and the reverse import order caught it. An order-dependent rail is not a rail, and the battery now carries both orders. The module-versus-module pair for a name the prelude does NOT provide stays E609's, which a control pins. The acceptance battery is a parameterized test over all eight names in both shapes plus the restatement control, asserting that no cell reports `[E602]`/`[E620]` and that no zero-exit compile is missing a function, so the four-of-eight coverage the rail started with cannot return silently; `ch08_module_prelude_adt_contention_rejected` pins the same refusal at conformance level, paired with the positive that imports the same module and never names the type. The conformance manifest gained an `expected_error_stage` key for it (`"check"`, the default, or `"compile"`): a compile-stage negative asserts that the program type-checks CLEANLY and is then refused by `vera compile` with the declared code, which is the property a codegen-phase diagnostic exists for and which the check-only negative path could not express. Reserving the prelude's names is not the fix and is not done: §8.4.1 forbids it. + +- **The prelude's declaration-index block no longer depends on what the main file declares** ([#1287](https://github.com/aallan/vera/issues/1287)). `_stamp_decl_order` guarded its PRELUDE write on `_decl_order`, the ACTIVE (main-file) namespace — but `_prelude_decl_order` is not a namespace: `_module_alias_scope` builds every module's index space as `{**prelude, **module_own}`, so it is the base layer under all of them and its contents are a fact about what `inject_prelude` laid down. A main-file `type Option = Int` is accepted (§8.4.1 again) and, being an alias rather than a `data`, does not suppress the prelude's own `data Option`, so the guard fired on the prelude stamp: `Option` was left out of the block entirely, and every later prelude declaration shifted one place earlier because the skipped stamp never advanced the counter. Inside a module namespace the prelude's `Option` then reached `AliasEnv.data_types` at `_BUILTIN_DECL_INDEX` — below `_PRELUDE_DECL_BASE`, so ordered ahead of every other prelude declaration rather than among them — which is exactly the cross-namespace leak `_decl_order` and `_module_decl_order` were split apart to prevent. Latent at emission: that map changes a rendering only for `Decimal` and the single `REMOVED_ALIASES` entry `Float`, and no prelude ADT is either, so no WAT moves; the defect is the wrong value reaching the consumer. The prelude write is now unconditional and the ACTIVE space still takes the main file's stamp, so the shadow keeps winning its own namespace. Pinned as an invariance — the same program with and without the shadowing alias must stamp an identical prelude block — with the main-namespace control that a fix stamping `_decl_order` unconditionally would fail. + +- **A user-defined `fn get` / `fn put` is no longer hijacked by an enclosing handler** ([#1284](https://github.com/aallan/vera/issues/1284)). Three sites answered "does this `get` mean the user's declaration or the effect operation?" independently. The checker answers user-fn-first — `_check_call_with_args` looks a bare name up as a function before it looks it up as an operation, so a declaration named `get` owns every bare `get(...)` in its scope, which an arity or argument-type error at such a call site proves by reporting the *user's* signature (E201/E202). Codegen answered twice more: the declared-effect row in `vera/codegen/functions.py` withheld the intrinsic when `_fn_sigs` already owned the name, and the handler expression in `vera/wasm/calls_handlers.py` installed `get`/`put` unconditionally. From `vera check`-green source that produced, depending on the nesting shape, a **silently wrong value** (`nat_to_int(get(3))` under `handle[State](@Int = 5)` returned the cell's 5 for the function's 4, and the argument was not even emitted), a **module WASM validation rejects** (a `@Bool`-returning user `get` took `state_get_Int`'s i64 into an `i32` position; different-family nesting took the *enclosing* cell's getter at the wrong width), or a **spurious `[E602]`** in which the #1233 unaddressable-cell gate refused `main` outright, naming "a bare or qualified State operation `get`" the program never contained. The repair is one predicate, `vera.slots.bare_call_denotes_user_fn`, stating the checker's rule once and consumed by the bare-call dispatch in `vera/wasm/calls.py` (which now gates the clause-inline registry, the host-cell intrinsics and the addressability gate together), by the three bare-`FnCall` result-type inference sites, and by the monomorphizer's discovery walk — each passing its own name table, so the sites cannot answer differently about the table they share. Gating the *dispatch* rather than the *registries* is what makes it correct rather than merely consistent: the registries record which cell an op name reaches, which is true whatever the program's declarations are called, and withholding an entry answered both questions with one table. That is why the gate-only fix measured during PR #1283's review turned the loud skip into a differently-broken module, and why it also cost the qualified spelling its cell — `State.put(5)` in a function that also declares `fn put` compiled to `call $vera.put` and failed to link, which now lowers to the intrinsic the checker always meant. Discovery's `MonoContext.fn_names` moves to the same lookup-time question, so a `get(())` fixing a generic's type variable under a handler names the clone the rewrite emits. All 256 conformance and example programs emit byte-identical WAT. **The `W002` async-commutativity warning was the same defect in the checker's own file** and is corrected with them: `_collect_expr_effects` asked `lookup_effect_op` before the scoped function lookup — the last op-first consumer — so a user function named after an operation contributed the *operation's* parent effect to the commutativity analysis instead of its own declared row, wrong in both directions. A **pure** `fn get`, in a program containing no `State` at all, drew `async argument performs State effects`; a `fn get` that performs `IO`, under a row naming `Http` first, drew **no warning**, because the walk bound the name to `Http.get`, which is inside the commutative whitelist, and silently withheld the eager-evaluation warning the program is owed. Both are pinned with rename controls — the byte-identical program with the helper called `gett` / `fetch` was correct throughout — beside a control that an unshadowed bare `get(())` under a `State` row still warns, so the fix cannot degenerate into never reporting `State`. The walk's comment claiming it resolves "like the call checker above" is now true. One caveat the predicate does not close: codegen's name table is not scope-accurate, so a name the call site cannot see still answers "user-owned" there — an invisible import by either of two routes, or a `where` helper of a `forall` parent, which unlike a non-generic parent's keeps a bare key. From check-green source that is a silent wrong value, a load failure, or an E-code-less `unknown func` depending on the route, tracked as [#1299](https://github.com/aallan/vera/issues/1299); a property of the table rather than of the rule. - **`new(State)` reads the cell its contract names** ([#1285](https://github.com/aallan/vera/issues/1285)). `old(State)` has been keyed on the resolved cell family since [#1205](https://github.com/aallan/vera/issues/1205)/[#1209](https://github.com/aallan/vera/issues/1209); `new(State)` read the name-keyed `_effect_ops["get"]`, which holds whichever `State` the effect row registered first. Under a single-`State` row the two keyings coincide, which is why the corpus agreed; under a multi-`State` row the two sides of one `ensures` clause read different cells. `effects(, State>)` with `ensures(new(State) == false)` was check-green *and* verify-green, emitted `state_get_Int`'s i64 into the Bool comparison's `i32.eq`, and died at load with wasmtime's raw `type mismatch: expected i32, found i64`. The type mismatch is the symptom rather than the defect: where both cells share a machine width — `State` beside `State` — the module loaded and answered about the other cell, so `ensures(new(State) == old(State))` on a function that writes neither was refuted at runtime on a contract the verifier had discharged. Codegen now carries a family→getter registry populated at the declared-row registration site from the per-family `CellNames` it already computes, and `_translate_new_expr` keys on `_state_effect_family` exactly as `_translate_old_expr` does. A bare `get(())` names no family and so is right to keep reading the name-keyed registry, source-order-first-wins; a contract names one and must not. diff --git a/CLAUDE.md b/CLAUDE.md index 397786cb..afdc0416 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,9 +58,9 @@ VERA_JS_COVERAGE=1 pytest tests/test_browser.py -v # Browser tests with JS cove VERA_EAGER_GC=1 vera run file.vera # Force GC on every alloc (see ENVIRONMENT.md, debug knob for #593-class GC-rooting bugs) mypy vera/ # Type-check the compiler itself -python scripts/check_conformance.py # Verify all 226 conformance programs (positives pass their level; negatives fail with their expected_error E-code) +python scripts/check_conformance.py # Verify all 219 conformance programs (positives pass their level; negatives fail with their expected_error E-code) python scripts/check_examples.py # Verify all 42 examples parse + check + verify -python scripts/check_corpus_canonical.py # Verify all 274 corpus programs are in canonical form (vera fmt) +python scripts/check_corpus_canonical.py # Verify all 268 corpus programs are in canonical form (vera fmt) python scripts/check_examples_readme.py # Verify vera run commands in examples/README.md python scripts/check_spec_examples.py # Verify spec code blocks parse python scripts/check_readme_examples.py # Verify README code blocks parse @@ -92,7 +92,7 @@ See [`TOOLCHAIN.md`](TOOLCHAIN.md) for the CLI cookbook — driving the toolchai - `vera/` — Reference compiler: grammar, parser, AST, transformer, type checker, verifier, codegen, CLI - `examples/` — 42 example Vera programs (all must pass `vera check` and `vera verify`) - `tests/` — Test suite (unit tests + conformance suite) -- `tests/conformance/` — 226 conformance programs validating every language feature against the spec +- `tests/conformance/` — 219 conformance programs validating every language feature against the spec - `scripts/` — CI and validation scripts ## Writing Vera code @@ -129,7 +129,7 @@ Before changing code — **adding or removing** — write the test that proves y ## What not to break - Pre-commit hooks run mypy + pytest + conformance suite + example validation on every commit -- All 226 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) must *fail* `check` with their `expected_error` E-code +- All 219 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first - All 42 examples in `examples/` must pass `vera check` and `vera verify` - Version must stay in sync across `pyproject.toml`, `vera/__init__.py`, `docs/index.html`, `README.md`, and `uv.lock` (gated by `scripts/check_version_sync.py`); CHANGELOG.md must also carry a matching `## [X.Y.Z]` section - All tests must pass: `pytest tests/ -v` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 75ad30e8..02199f24 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -102,7 +102,7 @@ The **commit-stage** hooks (33, each gated to relevant files) include: - Lint with ruff (default rules) - mypy type checking - pytest test suite -- All conformance programs hold at their declared level — positives pass; the negatives fail `check` with their `expected_error` E-code +- All conformance programs hold at their declared level — positives pass; the negatives fail at the stage their `expected_error_stage` names (`check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses) with their `expected_error` E-code - All `.vera` examples type-check and verify cleanly - README, EXAMPLES.md, SKILL.md, HTML, and spec code blocks parse correctly - Documentation counts match live codebase diff --git a/FAQ.md b/FAQ.md index 6b67d906..3778afd8 100644 --- a/FAQ.md +++ b/FAQ.md @@ -236,7 +236,7 @@ None of this is Vera-specific, but it validates the design choices. The thesis i This is a real concern. LLMs are trained on trillions of tokens of Python, TypeScript, and JavaScript. A MojoBench study (NAACL 2025) found that even fine-tuned models achieved only 30–35% improvement over base models on Mojo code generation, illustrating the cold-start problem for new languages. -Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (226 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification. +Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (219 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification. ## How does Vera compare to Dafny / Lean / Koka / F*? @@ -279,7 +279,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 10,991 tests, including a 226-program conformance suite +- 10,884 tests, including a 219-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index faeb5ae0..7bd2d25a 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -9,8 +9,8 @@ Defects in shipped compiler, runtime, or tooling behaviour — this table matche | Bug | Issue | |-----|-------| | A module-declared generic instantiated at a type that comes from an EFFECT-OPERATION result never registers its clone. Codegen reports a compilation note (`call target 'mod$$$' not registered`) and silently drops the calling function, so the emitted module has no `main` — from a program `vera check` and `vera verify` both pass. Two conditions, bisected and both necessary: the generic is declared in a **module** (so its clone is the `mod$`-mangled form) and its type argument is inferred from an effect operation's result. Neither nesting under a `where` helper nor `private` visibility is needed — `public` fails identically. Controls that pass: a literal type argument, a type argument from a plain module-function result, and the identical shape with the generic declared in the entry file. This is the `mod$`-mangled REGISTRATION path for module generics (the #1274/#1281 machinery), distinct from #1299's scope-table divergence — measured identical on `release/v0.1.12`'s base and on the #1299/#1281 branch, so that fix neither causes it nor changes it. | [#1310](https://github.com/aallan/vera/issues/1310) | -| The checker resolves a bare call to a SIBLING function's `where` helper. Spec §5 makes a helper local to its parent, and the checker's own `_lookup_function_scoped` implements that — it walks the enclosing frame stack and reads each frame's direct helpers — but it then falls back to `env.lookup_function`, and `vera/registration.py` has recursed every helper into that flat `TypeEnv`. So a top-level `other()` calling `helperx(7)`, where `helperx` is a helper of an unrelated `holder`, is **check-green and verify-green** and then refused by codegen (`Function 'helperx' is not defined in this module and was not found in any imported module`) — the helper is emitted as `holder$where$helperx`, so the bare call has no target. Loud, never a wrong answer. The op-name variant is the one that matters for the #1284 ownership predicate: with the helper named `get` and the sibling reading a `State` cell by bare `get(())`, the checker binds the HELPER and reports `[E202] Argument 0 of 'get' has type Unit, expected Int` where spec §7.4 resolves the operation — so the checker rejects a program codegen compiles correctly, the two tables disagreeing in the CHECKER's direction for the first time. Codegen's `_scoped_fns` (#1299) implements the spec rule; the fix here is a checker change, whose new rejections need their own blast-radius pass. | [#1307](https://github.com/aallan/vera/issues/1307) | | `json_parse` of a lone-surrogate escape substitutes U+FFFD in the browser and crashes the reference host. `json_parse("{\"k\":\"a\\ud800b\"}")` — whose Vera source is plain ASCII, so nothing refuses it earlier — prints `{"k":"a�b"}` in the browser and kills `vera run` with a `UnicodeEncodeError` traceback natively. Both hosts decode a JSON `\uD800` escape into a string holding an unpaired surrogate, which has no well-formed UTF-8 encoding, and they then part company writing it into WASM memory: `TextEncoder` replaces it with U+FFFD and returns, while `_alloc_string`'s `.encode("utf-8")` raises. Neither host is obviously right — one silently swaps a different valid string for the requested one, the other refuses but through the raw-traceback path [#1302](https://github.com/aallan/vera/issues/1302) tracks. Sibling of [#1306](https://github.com/aallan/vera/issues/1306): both are the same question about `json_parse`'s domain — which inputs it accepts, and which call refuses the ones it should not. The canonical serializers are NOT implicated: `dumps_canonical` emits a raw surrogate where the browser's walk escapes it, but the string boundary eliminates surrogates before either can see one, so the divergence is unreachable from there. Fix direction: rule on whether a Vera `String` is UTF-8 by construction — if it is, `json_parse` refuses the escape on both hosts and says so; if it is not, both hosts need one agreed lossy or WTF-8 representation. | [#1308](https://github.com/aallan/vera/issues/1308) | +| The checker resolves a bare call to a SIBLING function's `where` helper. Spec §5 makes a helper local to its parent, and the checker's own `_lookup_function_scoped` implements that — it walks the enclosing frame stack and reads each frame's direct helpers — but it then falls back to `env.lookup_function`, and `vera/registration.py` has recursed every helper into that flat `TypeEnv`. So a top-level `other()` calling `helperx(7)`, where `helperx` is a helper of an unrelated `holder`, is **check-green and verify-green** and then refused by codegen (`Function 'helperx' is not defined in this module and was not found in any imported module`) — the helper is emitted as `holder$where$helperx`, so the bare call has no target. Loud, never a wrong answer. The op-name variant is the one that matters for the #1284 ownership predicate: with the helper named `get` and the sibling reading a `State` cell by bare `get(())`, the checker binds the HELPER and reports `[E202] Argument 0 of 'get' has type Unit, expected Int` where spec §7.4 resolves the operation — so the checker rejects a program codegen compiles correctly, the two tables disagreeing in the CHECKER's direction for the first time. Codegen's `_scoped_fns` (#1299) implements the spec rule; the fix here is a checker change, whose new rejections need their own blast-radius pass. | [#1307](https://github.com/aallan/vera/issues/1307) | | `json_parse` accepts `NaN`, `Infinity` and `-Infinity` on the reference runtime and refuses them in the browser. The reference host calls `json.loads`, whose default `parse_constant` admits all three; the browser host calls `JSON.parse`, which refuses them as RFC 8259 requires. Measured through identical module bytes under both runtimes, `NaN`, `Infinity`, `-Infinity` and a nested `[NaN]` each take the `Ok` arm natively and the `Err` arm in the browser — a §12.9.3 divergence on the shared surface, and one no test covered because every JSON input in the parity battery is RFC-conformant. The hosts also disagree about *which call* refuses: the browser returns a handled `Err` from the parse, while natively the value parses and the following `json_stringify` raises the non-finite refusal [#1293](https://github.com/aallan/vera/issues/1293) installed, which [#1302](https://github.com/aallan/vera/issues/1302)'s host-callback path then presents as a Python traceback. Fix direction: hand `json.loads` a `parse_constant` that rejects, so the reference host refuses at the point RFC 8259 and the browser already do. | [#1306](https://github.com/aallan/vera/issues/1306) | | `json_keys`' result cannot be used from compiled Vera. The `Some` arm of its `Option>` binds a match binder of type `Array`, and codegen emits that binder's local as `(local $lN i32_pair)` — `i32_pair` being the internal String `(ptr, len)` spelling rather than a WASM value type — so the module fails WAT assembly with `unexpected token, expected one of: i32, i64, …`. A ten-line `public fn key_count(@Json -> @Int)` matching `json_keys` and calling `array_length` on the binder passes `vera check` and fails `vera compile`; `string_join` on the same binder fails identically. Loud rather than wrong, and narrow: `array_length(map_keys(...))` compiles, so `Array` alone is fine, and `json_get_array`'s `Some(@Array)` binder compiles, so a binder alone is fine — it is the pair that breaks. Fix direction: give the binder's local the array's own value type instead of its element's. | [#1305](https://github.com/aallan/vera/issues/1305) | | A non-finite `JNumber` reaching `json_stringify` fails with the right sentence and the wrong presentation: `vera run` emits a **61-line, 3,214-character Python traceback** — 15 stack frames, 7 of them inside `.venv/`, and **no mention of the user's `.vera` file at all** — with the actual message (`json_stringify: NaN is not representable in JSON — RFC 8259 has no NaN or Infinity. …`) as its last line. A contract violation in the same program shape produces **5 lines**: the failed clause and a source backtrace with `.vera` line numbers. The buffered stdout is discarded on the way out rather than surfaced, so the `#522` output-preservation contract is lost too. The invariant this breaks is already written down, in a comment on `host_print` in `vera/codegen/api.py`: "A user-level program must never produce a Python traceback regardless of what it does — the `WasmTrapError` contract from #516/#522/#547 holds here too." Root cause: `execute()` converts an exception into `WasmTrapError` only when `type(exc).__name__` is `Trap` or `WasmtimeError`, and a `ValueError` raised inside a host callback arrives at that handler as a `ValueError`, so the branch is skipped — no classification, no source-map frame resolution, no captured output. `json_stringify` is the case that surfaced it ([#1293](https://github.com/aallan/vera/issues/1293)), but the gap is generic: any host callback raising a non-`Trap` Python exception takes the same path. Fix direction: give host-callback failures a Vera-native shape — a dedicated error type classified alongside the traps, or widen the conversion to any exception escaping a host import — carrying the captured stdout and a source-mapped backtrace either way. | [#1302](https://github.com/aallan/vera/issues/1302) | @@ -19,9 +19,7 @@ Defects in shipped compiler, runtime, or tooling behaviour — this table matche | Spec §1.4 says its reserved keywords MUST NOT be used as function names, and for **seventeen of them nothing enforces it**: `then`, `else`, `data`, `type`, `module`, `import`, `public`, `private`, `requires`, `ensures`, `invariant`, `decreases`, `effect`, `with`, `in`, `where` and `pure` all declare and pass `vera check` as `public fn (@Int -> @Int)`. (`handle` is an eighteenth acceptance and is the sanctioned host-entry-point carve-out, §5.2; the other eleven names — `resume`, `exists`, `forall`, `match`, `assert`, `assume`, `fn`, `let`, `if`, `true`, `false` — are refused **E153**.) They are not even traps: `private fn with(@Int -> @Int)` and its `where` / `type` / `pure` / `requires` / `import` siblings are **callable** from the same file, so the declaration resolves and runs like any other function. The comment above `_KEYWORD_FN_NAMES` in `vera/checker/registration.py` gives the opposite as the reason those names are absent from the set — that the contextual lexer "does not admit them as a function name, so no declaration reaches this checker at all" — so the omission rests on a premise the tree refutes, and neither the parser nor the checker holds the MUST. Two resolutions, and the choice is a ruling rather than a mechanical fix: extend `_KEYWORD_FN_NAMES` so the seventeen join `resume` under E153, or narrow §1.4's MUST to the names actually reserved. The `resume` precedent leans to enforcing — `resume` is likewise not a keyword token anywhere and likewise parses, and was reserved *at the checker* precisely because the parser had no keyword to refuse it with — and E153's own rationale, that a keyword names one construct and nothing else (DESIGN principle 3), does not depend on the name being a trap. Until the ruling lands, §1.4's `handle` carve-out sentence deliberately makes no enforcement claim, so the spec does not assert a MUST it cannot back. | [#1296](https://github.com/aallan/vera/issues/1296) | | Spec Chapter 10 is a second, hand-maintained copy of `vera/grammar.lark`, and the rule-name alignment gate `scripts/check_grammar_alignment.py` compares only rule-name headers — three classes of drift pass it unseen. **Terminals, both directions**: the header pattern requires a lowercase lead, so a fabricated terminal added to §10.2 leaves the gate green, and no declared-versus-referenced audit exists in either direction. **Rule references**: restoring a removed ambiguity to a production's right-hand side is invisible, because only headers are compared. **Production bodies**: the class most grammar edits actually fall into. Two instances are live in the chapter on `main` today, both body-level and both this issue's to close: typed holes (`"?" -> hole_expr`, in `grammar.lark` since 2026-03-30) appear nowhere in `primary_expr`, and §10.2's `BLOCK_COMMENT: /\{-[\s\S]*?-\}/` is non-nesting, contradicting both §1.3 ("They nest") and the implementation, which parses `{- a {- b -} c -}` clean. Never a wrong answer from a program — the defect is that the published grammar misdescribes the one the parser has. Fix direction: extend the gate side-aware (terminal audit both ways, reference-set comparison), or fold Chapter 10 toward the DESIGN Grammar row's actual promise of a single-sourced shared grammar rather than a hand-maintained copy held honest by ever-wider cross-checks; the two live instances are fixable independently of which direction wins. | [#1290](https://github.com/aallan/vera/issues/1290) | | `.github/workflows/release.yml`'s `Tag and create GitHub Release` step fails with `HTTP 422: Validation Failed — body is too long (maximum is 125000 characters)` when the CHANGELOG section `scripts/release.py notes` extracts into `RELEASE_NOTES.md` exceeds GitHub's release-body limit. It fired on v0.1.10, whose notes extract to roughly 148,700 bytes — the #1213 burndown's 44-issue section, some 23,000 characters past the limit — and it fired at the worst point in the pipeline: **after** PyPI had accepted the immutable archives and **after** the tag was created, leaving the release half-cut with no repeatable path back. The v0.1.10 GitHub Release was completed by hand, mirroring the step exactly: the run's artifacts downloaded and hash-verified three ways, then `gh release create --verify-tag --latest` with the wheel, sdist and SHA256SUMS, and a generated body — the section's bold bullet lead-ins as a headline index plus a link to the canonical section at the tag. Rare, since it needs a release this large, but rarity is not the mitigating factor here; the landing point is. Fix direction: make the step total — before `gh release create`, regenerate oversized notes into that index form so the release always carries a body that fits, with the full notes staying in `CHANGELOG.md`, which is already the release notes of record. | [#1288](https://github.com/aallan/vera/issues/1288) | -| `_stamp_decl_order` skips the prelude stamp when a main-file `type` reuses a prelude name: `type Option = Int` checks green and leaves the prelude's `Option` without `_prelude_decl_order`, so inside a module namespace it resolves at `_BUILTIN_DECL_INDEX` instead of its prelude position. Latent — the shadow wins the arbitration by namespace rules before the index is consulted, and no observably-wrong check-green program has been constructed — but the stamp is wrong and the next decl-order consumer inherits it. Fix direction: stamp the prelude declaration unconditionally; its order is a fact about the prelude, not about what the main file declares. | [#1287](https://github.com/aallan/vera/issues/1287) | | `_infer_vera_type` (monomorphization's Vera-level type namer) still reads only `then_branch` of an `IfExpr` and only `arms[0]` of a `MatchExpr` — the #1276 join fix landed in its WAT-side siblings but not here. Confirmed by inspection; latent: a wrong answer needs an instantiation discovered through a divergent-first-arm conditional, and no check-green program reaching that shape has been constructed (the checker's upstream unification constrains the same joins). The fix is the mechanical #1276 mirror — take the first arm that yields a type — landing with the failing program that proves it, per the test-first rule. | [#1286](https://github.com/aallan/vera/issues/1286) | -| Codegen's per-namespace ADT membership (#1253) recovers the global infrastructure by subtracting what the namespaces DECLARE from the registered layouts, and that subtraction is not namespace-scoped: when a module declares `data Json`, `Json` leaves the infrastructure set and is therefore absent from the member set of every namespace except that module's — including the entry program's, which never declared it and legitimately sees the prelude's. The Pass-0.5 built-in snapshot unioned in as a floor predates the Pass-1.2 prelude injection and so does not protect these names (the same asymmetry #1253 fixed, one layer down), and no E609/E610 rail fires on the declaration because those rails key on that same snapshot. **Inert at emission today** — `AliasEnv.data_types` changes an answer only in `naming._resolve_named`, and only for `Decimal` and the single `REMOVED_ALIASES` entry `Float`, so the whole corpus is byte-identical with and without the membership rule; this is a latent constraint on the next consumer rather than a live defect, and it is not release-gating. Fix direction: scope the subtraction per namespace, or teach Pass 0.5 which prelude ADTs the program will demand — **not** by reserving the names, which spec §8.4.1 forbids (the prelude's data types are ordinary public declarations a program may name and shadow; the reserved namespace is the `Vera` prefix alone, E154). Recorded alongside it: a program where a module declares `data Json` AND the entry uses prelude `Json` checks clean and then compiles to nothing (E602 inside the prelude's own `json_get`, an E620 cascade, no exports) — a diagnostic-quality problem, not soundness, since it refuses to emit rather than emitting something wrong, but the error points into `` instead of at the user's `data Json`. Within-namespace shadowing is unaffected. | [#1277](https://github.com/aallan/vera/issues/1277) | | A `throw` payload is statically obligated but not runtime-guarded: `throw(v)` narrows `v` into the `Exn` payload and the narrowing now carries the same obligation every other binding site does — a provably-violating value is a loud E503/E505 and an undischargeable one is a disclosed `tier3_unguarded` E504/E506/E531 — but codegen emits no predicate or sign guard on the payload (`throw` lowers straight to `throw $exn_` with the value on the stack), so an unverified `vera compile`/`run` still passes a violating value through. Same disclosed-unguarded class as the user-effect operation argument in #754 below, and the guard wants the same metadata; fix direction: emit the guard at the `throw` op-call site, reusing the #1203 `put`-store guard emission and the §2.6.5 boundary predicate lowering. | [#1268](https://github.com/aallan/vera/issues/1268) | | `ch05_closure_nat_return` (a run-level conformance program in the pre-commit + CI gate) trapped **once** in a full `check_conformance.py` run (`unreachable` in `main` — the sentinel `assert` or a GC shadow-stack guard) and has not reproduced in ~960 attempts across isolated, parallel, eager-GC, and hash-seed-swept executions; the emitted WAT is deterministic and correct. Suspected rare runtime/GC/wasmtime interaction, tracked so a future intermittent CI red resolves here instead of starting fresh. | [#996](https://github.com/aallan/vera/issues/996) | diff --git a/SKILL.md b/SKILL.md index 13dd81ff..b5d1b57b 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1953,6 +1953,8 @@ Cross-module compilation uses a flattening strategy: imported function bodies ar If two imported modules define a function, data type, or constructor with the same name, the compiler reports an error (E608/E609/E610) listing both conflicting modules. Rename one of the conflicting declarations in the source module to resolve the collision. Local definitions shadow imported names without error. +A module's data type may also collide with one the prelude provides (`Option`, `Result`, `Ordering`, `UrlParts`, `Json`, `HtmlNode`, `Request`, `Response`). One name carries one layout in the compiled program, so the two contend when their shapes differ — different constructors, a different constructor order, or different field types. The compiler then reports **E621** at the module's declaration; rename it there, or give it the prelude's shape. A module declaring `data Json` is legal on its own, because the prelude injects `Json` only when the entry program uses it; `Option`, `Result`, `Ordering` and `UrlParts` are in every program, so a differently-shaped module declaration of one of those always contends. Declaring the type in the **entry** file instead suppresses the prelude's own, so it never contends with the prelude — but it does not settle a clash with a module that also declares the name, which is a separate pair the compiler does not yet arbitrate ([#1312](https://github.com/aallan/vera/issues/1312)). + Type aliases and effect declarations are module-local and cannot be imported. If another module needs the same alias or effect, it must declare its own copy. Module-qualified calls use `::` between the module path and the function name: `vera.math::magnitude(42)`. The dot-separated path identifies the module and `::` separates it from the function name. This syntax can be used anywhere a function call is valid, and always resolves against the specific module's public declarations — it is not affected by local shadowing. Note: module-qualified calls (`math::magnitude(42)`) are available for readability but do not yet resolve name collisions in flat compilation — the compiler will still report a collision error. A future version will support qualified-call disambiguation via name mangling. diff --git a/TESTING.md b/TESTING.md index 85457f8b..1ca55b8b 100644 --- a/TESTING.md +++ b/TESTING.md @@ -6,9 +6,9 @@ This is the single source of truth for Vera's testing infrastructure, coverage d | Metric | Value | |--------|-------| -| **Tests** | 10,991 across 166 files (~155,000 lines of test code; 10,814 passed + 26 stress, 151 skipped) | +| **Tests** | 10,884 across 166 files (~155,000 lines of test code; 10,714 passed + 26 stress, 144 skipped) | | **Compiler code coverage** | 95% Python, 87% JavaScript (CI minimum: 80%) | -| **Conformance programs** | 226 programs across 9 spec chapters, validating every language feature | +| **Conformance programs** | 219 programs across 9 spec chapters, validating every language feature | | **Example programs** | 42, all validated through `vera check` + `vera verify` | | **Spec code blocks** | 189 parseable blocks from 14 spec chapters: 92 parse, 86 type-check, 85 verify (the rest carry inline `vera:skip` annotations, #538) | | **README code blocks** | 4 Vera blocks (4 validated, 0 annotated) | @@ -39,7 +39,7 @@ VERA_EAGER_GC=1 pytest tests/test_codegen_closures.py::TestClosureReturnShadowPu mypy vera/ # strict mode # Validation scripts -python scripts/check_conformance.py # conformance suite (226 programs, see manifest.json) +python scripts/check_conformance.py # conformance suite (219 programs, see manifest.json) python scripts/check_examples.py # 42 example programs python scripts/check_spec_examples.py # spec code blocks python scripts/check_readme_examples.py # README code blocks @@ -79,6 +79,8 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_handler_op_ownership_1284.py` | 15 | 419 | #1284 — whose declaration a bare `get`/`put` call site denotes. The checker resolves user-fn-first (pinned directly: an over-applied `get` under a `handle[State]` reports the USER signature's arity), and codegen used to answer that question twice more and differently — the declared-effect row withheld the op when a function owned the name, the handler expression overwrote unconditionally. Four shapes from check-green source, each asserted on the CHECKER's value and on the dispatch target in the emitted WAT: a handled body returning the cell instead of the function's answer (silently 5 for 4), a `@Bool`-returning user `get` whose module WASM validation rejected, same-family nesting refused outright with a spurious `[E602]` naming a State operation the source never contained, and different-family nesting emitting the enclosing cell's getter at the wrong width. A parametrized differential runs all five shapes (the four plus a user `put`) as one table with each case's pre-fix behaviour recorded, so a case that stops distinguishing the two answers is visible rather than vacuous; the controls — an unshadowed handler and an unshadowed declared row, both of which must still reach the intrinsics — are what a fix that simply stopped installing the ops would fail, and `new(State)` under a shadowed op name pins that the #1285 family registry composes with this | | `test_new_state_family_1285.py` | 9 | 323 | #1285 — which cell `new(State)` reads under a multi-`State` effect row. `old()` has been family-keyed since #1205/#1209 while `new()` read the name-keyed op registry, so the two sides of one `ensures` clause read different cells: `effects(, State>)` with `ensures(new(State) == …)` was check-green and verify-green, put `state_get_Int`'s i64 into the Bool comparison's `i32.eq`, and died at load. Three multi-row cases — the width-mismatched shape that could not load, an `Int`/`Nat` pair that loaded and answered about the wrong cell, and `old()` beside `new()` of one family, whose unchanged-cell claim the runtime refuted on a contract the verifier had discharged — plus the single-`State` and alias-spelled controls the whole existing corpus exercises. Each cell is seeded from a caller's handler at a value the other cell is not holding, so a wrong-cell read cannot coincide with the right answer, and a deliberately false postcondition asserts the Tier 3 runtime check really traps, without which every "the program runs" assertion here would prove nothing | | `test_adt_membership_scope_1253.py` | 5 | 334 | #1253 — a checker↔codegen DIFFERENTIAL over one module's slot table. `_adt_layouts` is one map across every absorbed namespace, so a sibling module's ADTs were members of a module that never imported them while the checker kept the name opaque: `['Array', 'Array']` against `['Array', 'Array']` for the same signature. Each case renders the module's parameters through `vera.naming` twice — once against the environment the checker binds that module's declarations in (built by the production `_modules_visible_to` + `check_program` path, not a rebuild of it) and once against codegen's `_alias_env` inside `_module_alias_scope` — and asserts both the agreement and the checker's own value, so an alignment on the WRONG name still fails. Three membership cases (an unimported public sibling, a private sibling, and the imported positive control that is green before and after — what separates scoping the membership from erasing cross-module ADTs) plus the entry program's own view, which must keep seeing the ADT it imports by name | +| `test_prelude_decl_stamp_1287.py` | 4 | 215 | #1287 — the prelude's declaration-index block is a fact about the prelude. `_stamp_decl_order` guarded the PRELUDE write on `_decl_order`, the active (main-file) namespace, so a main-file `type Option = Int` — accepted under §8.4.1, and not a `data`, so it does not suppress the prelude's own `Option` — made the guard fire and left `Option` out of `_prelude_decl_order` entirely, with every later prelude declaration shifted one place earlier because the counter never advanced. That map is the base layer under every module's index space (`{**prelude, **module_own}`), so the wrong index reached `AliasEnv.data_types` as `_BUILTIN_DECL_INDEX`. Stated as an INVARIANCE — the same program with and without the shadowing alias must stamp an identical prelude block — plus the module-namespace index it feeds, and a control that the main file's own stamp still wins its own namespace (which a fix stamping `_decl_order` unconditionally would break) | +| `test_prelude_adt_namespace_1277.py` | 49 | 808 | #1277 — one file's `data Json` must not evict the prelude's from another namespace, and a module declaration contending with a prelude one must be loud. Three halves, pinned by disjoint cases so a regression in any is attributable. **Acceptance battery**: all eight prelude ADT names × {module declares it alone, entry also uses the prelude's}, asserting that no cell reports `[E602]`/`[E620]` and that a cell which does not report `[E621]` emits every public function the entry declares — the silent-drop check, and the guard against the rail's original four-of-eight coverage returning (the layout harvest skips a built-in name, so a layout-keyed rail saw `data Json` and never `data Option`). Plus two-declaring-module cells in both import orders (restate+differ, both-differ, both-restate, and a non-prelude control that must stay E609's), because the rail asks every declarer and a first-wins lookup made it order-dependent. Plus the restatement control for all eight: a module that restates the prelude's shape shares the one layout, compiles and runs, and must not be refused — measured legal at the branch point, and refused by the rail's first form for four of them. **Rail detail**: severity, the module's own file and line, the description naming the type and the module, the empty exports, and `cmd_compile` returning 1 over a `cmd_check`-green program. **Floor**: the checker registers the prelude ADTs in every `TypeEnv` unconditionally, so codegen's membership must too; asserted as a differential against the checker's own `data_types` in a module namespace, and on the entry namespace's member set for the issue's measured shape. `prelude_adt_names()` is compared against `inject_prelude` itself, and `data_decl_shape` is pinned on both directions that matter — a renamed type parameter is the same layout, a reordered constructor is not | | `test_import_visibility_entry_point_1244.py` | 6 | 269 | #1244 — `vera check` reports the same diagnostics whether it was given a module or a file that imports it. Registration alone says what a module DECLARES; the importer never checked its bodies, so a name a module never imported was rejected standalone (E200, §8.5.1) and accepted in silence through an importer. Written as EQUALITY between the two entry points rather than as "the importer warns", because the property is agreement — a future change making the standalone verdict lenient would satisfy a one-sided assertion and must fail here on the standalone leg. Six cases: the leaked unimported name, the honest control that imports what it uses (green before and after, so the new body check is the visibility rule rather than a blanket rejection of cross-module programs), the issue's type-error-through-importer shape (an `@Int` call bound to a `@Bool` slot: check-clean, Tier-1, failing at compile), a diamond proving each module is reported ONCE (the body check is memoised by path across nested checkers), and both entry points into an import cycle proving the memo terminates it | | `test_clone_body_declaring_module_1241_1243.py` | 5 | 344 | #1241 + #1243 — an imported generic's clone body resolves its bare calls in the DECLARING module, on both sides. The verifier's lexical lookup fell through to the importer's registry (`_declaring_module_scope` swapped the naming env, source and file but not the function registry) and codegen's clone-emission door was the one door that did not thread the module's intra-rename map, so `glib`'s `gen` called the importer's `need`. The two halves are one routing rule, and the tests are written so neither passes alone: each case asserts the `vera verify` verdict AND the `vera run` value together, so the verifier half alone (which makes verify clean while the compiled program still traps on the postcondition it just proved — the measured false Tier-1) fails the same test the codegen half alone (right value, verify still refusing) fails. Every expected value comes from the module verified and run STANDALONE, never from what the importer produces. Shapes: a private direct callee, a two-hop private chain, the type-discriminating pair (`@Int` vs `@Bool` — check-green source that emitted invalid WASM), and the unshadowed-callee control that was correct before and after, which is what pins the defect to the SHADOWED name rather than to cross-module calls in general | | `test_module_generic_namespace_1274.py` | 24 | 1,015 | #1274 — a module generic that does not own the importer's bare name is reached under `mod$$name`. Pre-fix only PRIVATE module generics were routed that way (#1000), so a PUBLIC one collided with the importer's same-named generic in the clone-name space: both files' `gen2` mangled to one `gen2$Bool`, one overwrote the other, and the module's own body ran the importer's — a **false Tier-1** (`check`/`verify` clean, the module's proved `ensures` violated at run: 999 where the declaring module answers 111). The full visibility matrix (module generic × importer generic), the import-filter dimension (out-of-filter, in-filter, wildcard), the unshadowed-out-of-filter cell that assembled to `unknown func $gen2`, and a type-discriminating shape whose two clones have different WAT result types. Each cell asserts the verify VERDICT and the runtime VALUE together in one test — a clean verify beside a violated postcondition IS the bug, so splitting them across sibling tests would let it hide — against the standalone library as oracle, and re-checks that the importer's own generic still answers its own value. The per-module both-sides differential lives in `test_monomorphize_differential.py`. Two further families joined after the adversarial round: the module→module **hop** — a module's bare call to a DIFFERENT module's qualified-only generic, which the per-module classification never rerouted, in both a LOUD spelling (a contract pins the answer, so a captured call traps) and a SILENT one (every contract admits both answers, so only the value distinguishes them) plus the two-hop shape where the entry never imports the declaring module at all; and the **shared-input** pair, which pins that the two sides compute the importer's occupied bare names identically — codegen reads them after Pass 0's helper renames, the verifier from the pre-transform AST, and a non-generic `where`-helper named `gen2` made the same imported generic bare-name-owning on one side and qualified-only on the other. The idempotence of that derivation is asserted directly across BOTH Pass-0 transforms and their composition — over a fixture carrying every helper shape it distinguishes (non-generic under non-generic, under a generic parent, under a generic helper, and a generic helper), since a fixture missing one would let a partial assertion look total — with each shape's membership pinned individually beside it, because idempotence alone would hold for a derivation that answered the same WRONG set every time. Two more families close the visibility dimension: the **transitive** one, driven through the production `ModuleResolver` (a hand-built `ResolvedModule` defaults `direct=True` and would never reach the path), asserting that a module reached only transitively has ALL its generics qualified-only — the entry's namespace does not hold them at all; and the **user-written qualified call** (`deep::gen(true)` where the importer declares its own `gen`), which must key its instantiation to the module's declaration rather than to whoever owns the bare name | @@ -97,7 +99,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_checker_errors.py` | 73 | 1,196 | Error codes, resolution-coverage diagnostics, contracts, error accumulation (#420 split); cyclic type aliases incl. #1059 self-reference through a type argument (`Future`, mutual `Future`/`Future`, `Array`) rejected E132 | | `test_checker_builtins_collections.py` | 97 | 848 | Map / Set / Decimal / Json / Html / Http / Inference built-in type-checking (#420 split) | | `test_checker_builtins_strings.py` | 122 | 945 | String / numeric / type-conversion / float-predicate / string-search / markdown / regex built-in type-checking, removed-legacy-name regression (#420 split) | -| `test_obligations.py` | 744 | 1,741 | Reified proof obligations + warm `VerificationSession` (#222 Phase A): full-corpus differential oracle (warm session == cold `verify()` on diagnostics, summary, and obligation stream, plus warm-twice determinism, across all 42 examples and every verify/run-level conformance program), summary↔obligation tier-bookkeeping consistency (including the #967 `total == tier1_verified + tier3_runtime` leg, plus a focused self-consistency pin on the three call-demotion examples), the #1242 stream partition — over a corpus widened to every conformance program that type-checks, at any level, `len(obligations) == total + violated + tier3_unguarded` and every status is one of the documented five, with the vocabulary read from the `ObligationStatus` Literal so a sixth member fails rather than vanishing from the counts — per-kind unit tests (requires / ensures / decreases / nat_sub / call_pre statuses, counterexamples, error codes), content-key stability + same-text-two-sites span disambiguation, session solver reuse, type-error short-circuit, ADT-registry resync between programs; plus the Phase B incremental suite — identical-source full replay, callee-body-edit replays callers while callee-contract-edit invalidates them, span-shift and ADT-edit conservative invalidation, cross-program isolation, timeout-status never cached (monkeypatched solver), FIFO eviction bound; plus the #727 dedup pin — a violating call in a let RHS records exactly one E501 diagnostic and one call_pre obligation; plus the #1208 call-site rendering pin — a PARAMETERISED callee slot substitutes into the E501 message and its fix instead of falling back to the generic wording | +| `test_obligations.py` | 722 | 1,741 | Reified proof obligations + warm `VerificationSession` (#222 Phase A): full-corpus differential oracle (warm session == cold `verify()` on diagnostics, summary, and obligation stream, plus warm-twice determinism, across all 42 examples and every verify/run-level conformance program), summary↔obligation tier-bookkeeping consistency (including the #967 `total == tier1_verified + tier3_runtime` leg, plus a focused self-consistency pin on the three call-demotion examples), the #1242 stream partition — over a corpus widened to every conformance program that type-checks, at any level, `len(obligations) == total + violated + tier3_unguarded` and every status is one of the documented five, with the vocabulary read from the `ObligationStatus` Literal so a sixth member fails rather than vanishing from the counts — per-kind unit tests (requires / ensures / decreases / nat_sub / call_pre statuses, counterexamples, error codes), content-key stability + same-text-two-sites span disambiguation, session solver reuse, type-error short-circuit, ADT-registry resync between programs; plus the Phase B incremental suite — identical-source full replay, callee-body-edit replays callers while callee-contract-edit invalidates them, span-shift and ADT-edit conservative invalidation, cross-program isolation, timeout-status never cached (monkeypatched solver), FIFO eviction bound; plus the #727 dedup pin — a violating call in a let RHS records exactly one E501 diagnostic and one call_pre obligation; plus the #1208 call-site rendering pin — a PARAMETERISED callee slot substitutes into the E501 message and its fix instead of falling back to the generic wording | | `test_verifier_contracts.py` | 95 | 898 | Z3 verification over the example corpus, trivial/ensures/if-else/let/multi-clause contracts, counterexamples, tier classification, arithmetic, verification summaries, Diverge effect, edge cases, string-length + string-predicate verification (#839 split) | | `test_verifier_nat_obligations.py` | 82 | 1,743 | **`@Nat` subtraction underflow obligation** (#520 — Path-A discharge via requires/path-conditions/path-aware Z3 refutation, pure-literal exclusion, Int-Int and Nat-Int exemptions) and **`@Nat` binding-site narrowing obligation** (#552/#747/#749 — Tier-1 `value >= 0` at let/call-arg/effect-op-arg/ctor-field/match-bind/destructure narrowing — a concrete site classifies `tier3_runtime` (codegen-guarded) while the effect-op argument and generic-instantiated constructor field classify `E504` (obligated but unguarded, #754/#757) whose rationale names its actual cause — an untranslatable value — rather than the untranslatable-or-timeout conflation #1251 removed, walker-recursion pins, `_narrows_into_nat` verifier/codegen soundness parity; PR #972 clone-instantiated side-table substitution — a `Some(@T)` bind in an `Option`-instantiated clone is no narrowing, genuine clone-path narrowings still obligated); #1201 — a builtin `Tuple` parameter's match-bound components carry their declared component facts (a valid ensures over one proves instead of falsely violating) and an `Int` component bound as `@Nat` fires one loud `E503` per component, both mutation-caught (#839 split) | | `test_verifier_primitive_ops.py` | 39 | 662 | **Primitive-operation safety obligations** (#680) — division/modulo by-zero `E526` and array-index-bounds `E527`, the in-bounds/out-of-bounds two-check with float-exemption, honest Tier-3 for opaque lengths, off-by-one and lower-bound pins, De Bruijn-correct fix hints (#839 split) | @@ -115,7 +117,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_soundness_392.py` | 36 | 584 | #392 audit batches 1–2 — verifier soundness/completeness fixes: signed div/mod truncate toward zero (#799), body `assert(P)` carries a Tier-1 obligation (#800), divisions in contract predicates carry a `div_zero` obligation (#801), and the #804 assume-half of #800's `assert` rule — a prior `assert`/`assume` discharges later obligations (including a later call's precondition) + the postcondition at Tier 1, removing false E501/E503/E500/E505 | | `test_int_overflow.py` | 6 | 143 | #798 — `@Int`/`@Nat` arithmetic-overflow obligations (part of the #392 `smt.py` soundness audit): `+`/`-`/`*` on `@Int`/`@Nat` now emit an `int_overflow` obligation (the analog of `nat_sub`/`div_zero`) rather than modelling the operands as Z3's unbounded integers, so a `ensures(@Int.result > @Int.0)` over `@Int.0 + 1` no longer proves a contract the i64/u64 runtime violates under two's-complement wraparound. Unbounded operands leave the obligation undischarged (Tier-3, runtime-guarded); operand bounds that prove the result stays in range discharge it at Tier 1 | | `test_int_overflow_codegen.py` | 62 | 718 | #798 Stage 3 — runtime overflow-trap codegen: the codegen emits a guard at *exactly* the `@Int`/`@Nat` `+`/`-`/`*` sites the verifier obligates, so `vera run`/`vera compile` programs trap on overflow instead of silently wrapping at the i64/u64 boundary. #808 wired the guard to the `vera.overflow_trap` host import, so the trap now classifies `kind="overflow"` (carrying the overflow Fix paragraph) rather than the generic `unreachable`; `TestOverflowTrapKind808` pins that, with controls proving the #520 `nat_sub` underflow and #813 `@Nat`→`@Int` widen guards still classify `unreachable` | -| `test_int_overflow_differential.py` | 250 | 398 | #798 Stage 3 verifier↔codegen classification differential (cross-component soundness rule): the codegen overflow guard must fire at exactly the sites the verifier obligates *and* classify each site's operand type (`@Int` i64 vs `@Nat` u64) identically — else a Tier-1-clean program traps spuriously or a wrapping op slips through unguarded. Over a corpus exercising all five operand combos plus the literal-left ambiguity (a naive codegen mis-classifies it as `@Nat`), asserts the verifier's per-site gated classification equals the codegen's site for site, both sides driven by the same `ast.span_key` | +| `test_int_overflow_differential.py` | 243 | 398 | #798 Stage 3 verifier↔codegen classification differential (cross-component soundness rule): the codegen overflow guard must fire at exactly the sites the verifier obligates *and* classify each site's operand type (`@Int` i64 vs `@Nat` u64) identically — else a Tier-1-clean program traps spuriously or a wrapping op slips through unguarded. Over a corpus exercising all five operand combos plus the literal-left ambiguity (a naive codegen mis-classifies it as `@Nat`), asserts the verifier's per-site gated classification equals the codegen's site for site, both sides driven by the same `ast.span_key` | | `test_nat_int_widening.py` | 36 | 602 | #813 — `@Nat -> @Int` widening coercion obligation (dual of #552 `nat_bind`, part of the #392 soundness audit): a `@Nat` in (i64.MAX, u64.MAX] reinterprets when widened (u64.MAX → -1), so a `nat_to_int_coerce` obligation that the value is `<= i64.MAX` now fires at the return position — provably-in-range → Tier-1, provably-out-of-range (`@Nat.0 >= 2**63`) → loud E530, unbounded → honest Tier-3 (runtime-guarded), with an `@Int -> @Int` control that must not fire; the unguarded generic-`@Int`-field case also has its `E531` rationale read for WHAT IT SAYS — a value bounded on neither side, not the untranslatable-or-timeout conflation #1251 removed. The #813 follow-up adds the explicit `nat_to_int` built-in and heterogeneous `if`/`match` arms with a non-negative-literal alternative; #820 adds the heterogeneous-`@Int`-slot arm, closure argument, and closure return/capture obligations (each per-arm / per-site, with `@Int`-arm and `@Nat`-formal controls that must not fire) | | `test_int_widening_codegen.py` | 52 | 535 | #813 Stage 3 — runtime `@Nat -> @Int` widening-trap codegen: the codegen emits a guard at *exactly* the `@Nat -> @Int` coercion sites the verifier obligates (return, `let`, call argument, and — since #820 — array element, tuple construction/destructure, heterogeneous `if`/`match` arm, closure argument/return), so `vera run`/`vera compile` programs trap when a `@Nat` above i64.MAX would reinterpret to a negative `@Int` instead of silently returning the wrong value. The trap is a bare `unreachable` (shares `_emit_negative_i64_guard` with the #552 nat-bind guard), classified `kind="unreachable"` today (a dedicated widening trap kind is a follow-up) | | `test_int_widening_differential.py` | 26 | 320 | #813 verifier↔codegen behavioural differential (cross-component soundness rule): at every `@Nat -> @Int` coercion site the verifier's `nat_to_int_coerce` classification must AGREE with the runtime — a `tier3` (codegen-guarded) site MUST trap on a `@Nat` above i64.MAX (return / `let` / call-arg / constructor field / ADT sub-pattern / match-bind, and the #820 array-element / tuple-component / heterogeneous-arm / closure argument-return sites), while a `tier3_unguarded` (E531) site must NOT trap (the generic-instantiated `@Int`-field coercion codegen cannot guard). Runs BOTH sides on one corpus so the "runtime-guarded" claim is checked against the actual trap — catching a verifier deferral codegen never guards (unsound silent -1) or a spurious trap | @@ -181,7 +183,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_string_length_soundness.py` | 15 | 278 | #802 — string_length code-point vs UTF-8 byte soundness: a non-literal `string_length` defers to Tier 3 (the issue's `"é"` probe no longer proves `== 1` at Tier 1), a string-literal length is modeled at its exact UTF-8 byte count (`== 2` for `"é"`), and the boolean predicates `string_contains` / `string_starts_with` / `string_ends_with` stay Tier 1 (sound under UTF-8 self-synchronization), while a predicate over an astral (> U+2FFFF) or lone-surrogate literal defers to Tier 3 (z3.StringVal cannot model those code points) | | `test_errors.py` | 62 | 657 | Error code registry, diagnostic formatting, serialisation, SourceLocation, and error display sync — the canonical `E001` diagnostic must match each of its mirrors: `README.md`, `docs/index.html`, `spec/00-introduction.md`, `AGENTS.md`'s example `--json` block, and the hardcoded example in `scripts/build_site.py` that generates `docs/index.md` (#829; `AGENTS.md`'s ellipsis-truncated description/rationale are prefix-compared, its `error_code`/`spec_ref`/`fix` exactly) | | `test_eq_contract_874.py` | 13 | 430 | `eq`/`compare` ability ops in contract position: codegen canonicalization + verifier Tier-1 discharge/counterexample, where-fn contracts, compare Ordering-sort materialization, shadowing guard (#874) | -| `test_formatter.py` | 521 | 3,638 | Comment extraction, interior comment positioning, expression/declaration formatting, match arm block bodies, §1.8 rule 2 in value position (a `let`-bound `match`/`if` expands exactly as one in statement position, and a comment above an arm inside a statement's value stays on that arm), blank-line preservation (§1.8 rule 13 — gaps between statements, before a block result and around a comment, collapsed to one and never invented), idempotency, parenthesization, spec rules, ability declarations | +| `test_formatter.py` | 514 | 3,638 | Comment extraction, interior comment positioning, expression/declaration formatting, match arm block bodies, §1.8 rule 2 in value position (a `let`-bound `match`/`if` expands exactly as one in statement position, and a comment above an arm inside a statement's value stays on that arm), blank-line preservation (§1.8 rule 13 — gaps between statements, before a block result and around a comment, collapsed to one and never invented), idempotency, parenthesization, spec rules, ability declarations | | `test_cli.py` | 273 | 4,612 | CLI commands (check, verify, compile, run, serve, test, fmt, version, quiet), subprocess integration, JSON error paths (including the `verify --json` `obligations` array and its summary-reproducibility pin, #967, and the #1242 partition pin — the array is emitted unfiltered, a refuted obligation is counted by no summary field, and it still joins its E500 on the location key), runtime traps, arg validation, multi-file resolution, IO exit codes, --explain-slots (including the #1208 naming pins — an alias in type-argument position is tabled resolved, and a `forall` variable shadowing a module alias keeps the two parameter stacks apart — and the #1217 `where`-helper tables: the helper prints indented under its parent, appears in the JSON qualified as `parent.helper`, and inherits the enclosing `forall` variables so the shadowing holds inside it too), `builtins`/`effects`/`errors` introspection dispatch, and a USAGE-completeness guard (every dispatched `cmd_` handler has a help row) | | `test_introspect.py` | 39 | 221 | `vera builtins/effects/errors --json` registry introspection (#539): the `{schema, items}` envelope, count-equals-registry differential per registry, error-phase derivation, effect/ability `kind` tagging, the parameterised `Exn` effect, and best-effort `since` attribution with full-coverage guards | | `test_resolver.py` | 20 | 602 | Module resolution, path lookup, parse caching, circular import detection, the E011/E012/E013 diagnostic contract, internal-error isolation (a compiler bug is not masked as E013), and the transitive-closure return of `resolve_imports` (#890 — a diamond yields each reachable module once, direct imports tagged `direct`, the transitive one not) | @@ -199,7 +201,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_markdown.py` | 94 | 610 | Markdown parser: block/inline parsing, rendering, round-trips, edge cases | | `test_lsp.py` | 146 | 2470 | LSP transport + coordinate layer (#222 Phase C) and language features (#222 Phase D): parametrized code-point↔UTF-16 goldens incl. astral-plane fixtures and surrogate-pair snapping, Span (1-based, exclusive-end) and SourceLocation (0-based col) → LSP Range conversions, point→token-range widening, DocumentStore open/change/close + index invalidation, an in-process handler-drive test, and one stdio end-to-end round-trip against the real `vera lsp` subprocess (initialize → didOpen → shutdown → exit) pinning serverInfo + textDocumentSync capabilities; plus the Phase D feature suite — parse-error single-diagnostic path, type-error verification short-circuit, tier=3 in E520 diagnostic data, per-function tier Hint synthesis (and its suppression for functions with violated obligations), smallest-enclosing-span hover, De Bruijn slot goto (most-recent-parameter jump, out-of-range None, off-slot None, and the #1208 keying pins: a parameterised reference resolves, an alias-spelled parameter is reachable from a canonically-spelled reference, and a `forall` variable shadowing a module alias lands on the right parameter), and typed-hole completion (inside/after hole, away-from-hole None); plus the Phase E speculativeEdit suite — identical-text all-unchanged, breaking edit surfaces newly_undischarged (violated nat_sub) with canonical state untouched, strengthening edit surfaces newly_discharged, parse/type errors report ok:false, deleted functions report removed, proof_delta purity; plus the Phase F1 proposeEdit suite — the apply gate (clean and strengthening edits apply, breaking and non-compiling edits refuse), force overriding both gates with the delta still reported, wiring against a structural fake server (apply round-trip with exact full-document replacement range, refuse touches no canonical state, unopened-URI clamp sentinel), and full-document-range goldens (trailing-newline virtual line, UTF-16 end column); plus the Phase F2 strengthenContract suite — splice goldens (first-clause-only replacement with byte-identical remainder, ensures variant, unknown-fn None), the call-site audit pin (tightened precondition refused with newly_undischarged call_pre items, canonical state untouched), provable-ensures strengthening applies, and the three splice-target refusal paths (no analysis, unparseable document, unknown function); plus the Phase F3 addEffect suite — transitive-caller closure goldens (diamond in declaration order, leaf, unknown-fn None, recursion appears once), handler bounding (#725: a caller discharging the effect around its only call site drops out of the closure and is not rewritten, while a second unhandled path, a call in a handler clause, a handler for another effect, a handler naming a different instance of the same effect (`handle[State]` against a `State` propagation, which the checker does not discharge — end-to-end that caller must still be rewritten and the candidate must still apply, with the matching `State` propagation against the same fixture as the positive control that this handler key does prune something), and a bare `where`-helper call all keep it in, as does a refinement type argument at either depth (`Exn<{ @Int \| p }>` and `Exn>` render as their bare base type but discharge nothing of `Exn` — also pinned end-to-end), while an unparameterised `handle[IO]` does bound an `IO` propagation, a whitespace-spelled `State< Int >` request still bounds, and nesting bounds in either order (a matching handler inside a foreign one, a foreign one inside a matching one) — plus a key-level pin that `handle[Mod.IO]` keeps its module, and the effect-less query pinned handler-unaware; plus the two boundary pins the review added — a call in the handler's STATE INITIALISER keeps its edge, since the initialiser is evaluated in the enclosing scope before the handler is installed (pinned beside the E125 the checker raises there against a `pure` caller, with the identical call in the handler body clean as the contrast), and an alias-spelled handler does not bound a `State` propagation though the checker discharges it (`handle[State]` with `type MyAlias = Int` — the spelling comparison's under-prune, #1292, with the alias-spelled request as the control that does prune)), effect-row rewrite goldens (pure to singleton set, source-preserving append, already-present None, base-name identity blocking State next to State), diamond propagation applying one multi-site candidate with the bystander untouched, mixed append/replace rows with already-satisfied callers skipped, the fully-satisfied no-op shape, and the two refusal paths; plus the #728 instruction-contract suite — the LSP message carries description, rationale, and the Fix: paragraph (also pinning single E501 emission at the LSP surface), and a bare diagnostic maps to the description alone | | `test_browser.py` | 347 | 4,733 | Browser parity: Python/wasmtime vs Node.js/JS-runtime output equivalence across IO, State, contracts, Markdown, Regex, and the examples the browser target can execute (two explicit lists in the file, not the whole `examples/` directory — interactive stdin, file IO, `DB` and the non-standalone `modules` example are excluded with their reasons recorded); plus the #349 `runtime.mjs` coverage battery — per-value-type and per-key-type Map variants, per-element-type Set variants, cold `Decimal` branches (exact-zero sign, negative-shift division, `decRoundPlaces` special cases, non-finite storage), `readJson`/`json_stringify` across every Json ADT tag, the Regex/Json `Result.Err` arms, and nested-Markdown walks. Two operations carry a canonical form the specification states rather than merely agreeing across the hosts, so their batteries assert more than equality: `json_stringify` (spec §9.7.1) pins the expected string on every Json ADT tag and on the number-rendering boundaries, checks three-pass idempotence, checks that a non-finite `JNumber` fails on both hosts *and* prints nothing, pins the object key orders an ordinary JS object cannot carry (array-index keys, which ES enumeration hoists to the front in ascending numeric order, and a `__proto__` key, whose assignment writes a prototype instead of a field) on both a parsed and a program-built object, and checks the reference host's own number rendering differentially against a real `JSON.stringify` over doubles drawn from raw bit patterns; `md_render` (§9.7.3) pins the expected render, re-renders it to prove the fixed point, runs the round-trip property over a corpus carrying the container and multi-line shapes a flat corpus misses, and renders `MdBlock` values the test builds directly, since several renderer rules — a container's child separator, an empty container, a code span wider than one backtick — are unreachable through `md_parse`. `md_parse` itself is not yet at parity and its remaining divergence classes are a tracked bug ([#1301](https://github.com/aallan/vera/issues/1301)); the suite pins the shapes the two implementations agree on | -| `test_conformance.py` | 1130 | 125 | Parametrized conformance suite: parse, check, verify, run, format idempotency across 226 programs | +| `test_conformance.py` | 1095 | 144 | Parametrized conformance suite: parse, check, verify, run, format idempotency across 219 programs; a negative entry fails at the stage `expected_error_stage` names (`check`, the default, or `compile` — which also asserts the program type-checks cleanly first) | | `test_prelude.py` | 29 | 585 | Prelude injection: Option/Result/array operation detection, combinator shadowing, type aliases, the reserved namespace every injected alias declaration lives in — checked against the checker's own E154 regex rather than a second spelling of the rule, since an alias the prelude injects outside it is one codegen resolves and the checker leaves opaque (#1184/#1221) — end-to-end compilation | | `test_checker_apply_fn.py` | 18 | 455 | #854 — `apply_fn` as a checker special form: zero-warning pins (API + CLI `--json` + closures.vera), E201 arity / E202 type / non-function-first-arg errors, E122/E125 effect-row enforcement for applied fn values, E151 redefinition rejection, variadic two-param application, prelude combinator regression pins | | `test_prelude_diagnostics.py` | 8 | 271 | #851 — prelude combinator skip-warnings: unreferenced-prelude E602/E604 suppression (zero-warning minimal compile, API + CLI `--json`), `` origin attribution for referenced-but-skipped combinators (text + `to_dict`), transitive reference scan, and user-fn warning locations pinned unchanged | @@ -250,14 +252,14 @@ Each conformance program declares the deepest pipeline stage it must pass: |-------|-------------------|------:| | `parse` | Source text is syntactically valid | 0 | | `check` | Parses and type-checks cleanly | 40 | -| `verify` | Type-checks and all contracts verified by Z3 | 20 | -| `run` | Compiles to WASM and executes correctly | 166 | +| `verify` | Type-checks and all contracts verified by Z3 | 15 | +| `run` | Compiles to WASM and executes correctly | 164 | Almost all programs are at the `run` level — they compile and execute, producing correct results. Forty programs (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch03_typed_holes`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_cross_module_contracts_lib`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch07_bare_effect_op_rejected`, `ch08_circular_import`, `ch08_cross_module_generic_lib`, `ch08_module_generic_diamond_base`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_transitive_module_import_base`, `ch08_visibility_private`, `ch08_xmod_widen_lib`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_eq_non_derivable_rejected`, `ch09_http`, `ch09_inference`, `ch09_ord_adt_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) are at the `check` level. Thirty-two of them — `ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch07_bare_effect_op_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, and `ch02_alias_cycle_rejected` — are **negative tests** that assert a specific diagnostic (E206, E135, E183, E201, E127, E153, E153, E153, E130, E130, E174, E182, E217, E011, E154, E154, E154, E154, E154, E154, E150, E152, E151, E242, E243, E207, E208, E208, E209, E128, E336, and E132 respectively) via the manifest's `expected_error` field; `ch09_http` and `ch09_inference` are environment-gated (network / API key). Twenty programs (`ch03_slot_let_chains`, `ch03_slot_noncommutative`, `ch04_nested_option_ctor`, `ch04_primitive_obligations`, `ch05_apply_fn_typing`, `ch06_adt_sort_disambiguation`, `ch07_cross_module_contracts`, `ch07_invisible_import_op_name_lib`, `ch07_io_read_char`, `ch07_io_sleep`, `ch07_random_effect`, `ch08_state_alias_module_table_lib`, `ch08_module_generic_diamond_mid1`, `ch08_module_generic_diamond_mid2`, `ch08_state_alias_per_module_lib`, `ch08_transitive_module_import_mid`, `ch09_http_server`, `ch09_invisible_import_ability_op_lib`, `ch09_math_builtins`, `ch09_nested_helper_family_op_name_lib`) are at the `verify` level, using Z3-provable contracts — a library module is pinned at the deepest level it reaches, so the two per-module alias-table libraries are verified rather than only checked. ### Skipped tests -`pytest tests/ -v` skips 93 conformance-stage tests across the two categories below (the suite's remaining skips are platform- or tool-gated and documented beside the tests that declare them): +`pytest tests/ -v` skips 95 conformance-stage tests across the two categories below (the suite's remaining skips are platform- or tool-gated and documented beside the tests that declare them): **Level-limited skips** — the conformance framework only runs tests up to the declared level; stages beyond that level are automatically skipped. These are expected and correct. @@ -311,6 +313,8 @@ Almost all programs are at the `run` level — they compile and execute, produci | `test_run[ch07_handler_state_type_mismatch_rejected]` | `ch07_handler_state_type_mismatch_rejected.vera` | `check` | `run` | `check`-level negative test: no `run` stage | | `test_verify[ch08_circular_import]` | `ch08_circular_import.vera` | `check` | `verify` | `check`-level negative test (`expected_error: E011`): verify stage not run | | `test_run[ch08_circular_import]` | `ch08_circular_import.vera` | `check` | `run` | `check`-level negative test: no `run` stage | +| `test_verify[ch08_module_prelude_adt_contention_rejected]` | `ch08_module_prelude_adt_contention_rejected.vera` | `check` | `verify` | `compile`-stage negative test (`expected_error: E621`, `expected_error_stage: compile`): verify stage not run | +| `test_run[ch08_module_prelude_adt_contention_rejected]` | `ch08_module_prelude_adt_contention_rejected.vera` | `check` | `run` | `compile`-stage negative test: no `run` stage | | `test_verify[ch08_reserved_vera_prefix_rejected]` | `ch08_reserved_vera_prefix_rejected.vera` | `check` | `verify` | `check`-level negative test (`expected_error: E154`): verify stage not run | | `test_run[ch08_reserved_vera_prefix_rejected]` | `ch08_reserved_vera_prefix_rejected.vera` | `check` | `run` | `check`-level negative test: no `run` stage | | `test_verify[ch08_reserved_vera_prefix_reference_rejected]` | `ch08_reserved_vera_prefix_reference_rejected.vera` | `check` | `verify` | `check`-level negative test (`expected_error: E154`): verify stage not run | @@ -372,7 +376,7 @@ tests/conformance/ ├── ch01_int_literals.vera # Chapter 1: Integer literals ├── ch01_float_literals.vera # Chapter 1: Float64 literals ├── ch01_string_escapes.vera # Chapter 1: String escape sequences -├── ... # 226 programs total, organized by spec chapter +├── ... # 219 programs total, organized by spec chapter ├── ch07_state_handler.vera # Chapter 7: State effect handler ├── ch07_exn_handler.vera # Chapter 7: Exn effect handler ├── ch09_numeric_builtins.vera # Chapter 9: Numeric built-in functions @@ -855,9 +859,9 @@ Twenty-eight scripts in `scripts/` validate cross-cutting concerns beyond unit t | Script | What it validates | |--------|-------------------| -| `check_conformance.py` | All 226 conformance entries hold at their declared level (parse/check/verify/run) — positives pass; the negatives fail `check` with their `expected_error` E-code | +| `check_conformance.py` | All 219 conformance entries hold at their declared level (parse/check/verify/run) — positives pass; the negatives fail at the stage their `expected_error_stage` names (`check` by default, or `compile`, which also asserts the program type-checks cleanly) with their `expected_error` E-code | | `check_examples.py` | All 42 `.vera` examples pass `vera check` + `vera verify` | -| `check_corpus_canonical.py` | All 274 corpus programs (recursive over `examples/` + `tests/conformance/`) are in canonical form under `vera fmt` | +| `check_corpus_canonical.py` | All 268 corpus programs (recursive over `examples/` + `tests/conformance/`) are in canonical form under `vera fmt` | | `check_examples_readme.py` | Every `vera run` command in examples/README.md references an existing file and exported function | | `check_spec_examples.py` | 189 parseable code blocks from spec chapters: parse, type-check, and verify | | `check_readme_examples.py` | All Vera code blocks in README.md parse correctly | @@ -963,9 +967,9 @@ The repository configures 35 hooks across two stages: 33 run at the commit stage | `ruff check .` | Lint Python with ruff (default `F` + `E` rules) | | `mypy vera/` | Type-check compiler in strict mode | | `pytest tests/ -q` | Run full test suite | -| `check_conformance.py` | All 226 conformance entries hold at their declared level — positives pass; negatives fail `check` with their `expected_error` E-code | +| `check_conformance.py` | All 219 conformance entries hold at their declared level — positives pass; negatives fail at the stage their `expected_error_stage` names (`check` or `compile`) with their `expected_error` E-code | | `check_examples.py` | All 42 examples pass `vera check` + `vera verify` | -| `check_corpus_canonical.py` | All 274 `examples/` + `tests/conformance/` programs (recursive) are in canonical form (`vera fmt`) | +| `check_corpus_canonical.py` | All 268 `examples/` + `tests/conformance/` programs (recursive) are in canonical form (`vera fmt`) | | `check_examples_readme.py` | `vera run` commands in `examples/README.md` reference existing files and exported functions | | `check_readme_examples.py` | README code blocks parse correctly | | `check_examples_doc.py` | EXAMPLES.md code blocks parse correctly | diff --git a/docs/SKILL.md b/docs/SKILL.md index c45ccf48..922de5b6 100644 --- a/docs/SKILL.md +++ b/docs/SKILL.md @@ -1909,6 +1909,8 @@ Cross-module compilation uses a flattening strategy: imported function bodies ar If two imported modules define a function, data type, or constructor with the same name, the compiler reports an error (E608/E609/E610) listing both conflicting modules. Rename one of the conflicting declarations in the source module to resolve the collision. Local definitions shadow imported names without error. +A module's data type may also collide with one the prelude provides (`Option`, `Result`, `Ordering`, `UrlParts`, `Json`, `HtmlNode`, `Request`, `Response`). One name carries one layout in the compiled program, so the two contend when their shapes differ — different constructors, a different constructor order, or different field types. The compiler then reports **E621** at the module's declaration; rename it there, or give it the prelude's shape. A module declaring `data Json` is legal on its own, because the prelude injects `Json` only when the entry program uses it; `Option`, `Result`, `Ordering` and `UrlParts` are in every program, so a differently-shaped module declaration of one of those always contends. Declaring the type in the **entry** file instead suppresses the prelude's own, so it never contends with the prelude — but it does not settle a clash with a module that also declares the name, which is a separate pair the compiler does not yet arbitrate ([#1312](https://github.com/aallan/vera/issues/1312)). + Type aliases and effect declarations are module-local and cannot be imported. If another module needs the same alias or effect, it must declare its own copy. Module-qualified calls use `::` between the module path and the function name: `vera.math::magnitude(42)`. The dot-separated path identifies the module and `::` separates it from the function name. This syntax can be used anywhere a function call is valid, and always resolves against the specific module's public declarations — it is not affected by local shadowing. Note: module-qualified calls (`math::magnitude(42)`) are available for readability but do not yet resolve name collisions in flat compilation — the compiler will still report a collision error. A future version will support qualified-call disambiguation via name mangling. diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 25274fc4..0e607c07 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -1915,6 +1915,8 @@ Cross-module compilation uses a flattening strategy: imported function bodies ar If two imported modules define a function, data type, or constructor with the same name, the compiler reports an error (E608/E609/E610) listing both conflicting modules. Rename one of the conflicting declarations in the source module to resolve the collision. Local definitions shadow imported names without error. +A module's data type may also collide with one the prelude provides (`Option`, `Result`, `Ordering`, `UrlParts`, `Json`, `HtmlNode`, `Request`, `Response`). One name carries one layout in the compiled program, so the two contend when their shapes differ — different constructors, a different constructor order, or different field types. The compiler then reports **E621** at the module's declaration; rename it there, or give it the prelude's shape. A module declaring `data Json` is legal on its own, because the prelude injects `Json` only when the entry program uses it; `Option`, `Result`, `Ordering` and `UrlParts` are in every program, so a differently-shaped module declaration of one of those always contends. Declaring the type in the **entry** file instead suppresses the prelude's own, so it never contends with the prelude — but it does not settle a clash with a module that also declares the name, which is a separate pair the compiler does not yet arbitrate ([#1312](https://github.com/aallan/vera/issues/1312)). + Type aliases and effect declarations are module-local and cannot be imported. If another module needs the same alias or effect, it must declare its own copy. Module-qualified calls use `::` between the module path and the function name: `vera.math::magnitude(42)`. The dot-separated path identifies the module and `::` separates it from the function name. This syntax can be used anywhere a function call is valid, and always resolves against the specific module's public declarations — it is not affected by local shadowing. Note: module-qualified calls (`math::magnitude(42)`) are available for readability but do not yet resolve name collisions in flat compilation — the compiler will still report a collision error. A future version will support qualified-call disambiguation via name mangling. @@ -3477,6 +3479,7 @@ Every diagnostic has a stable error code. Codes are grouped by compiler phase: - **E618**: Nested refinement base unsupported - **E619**: Cannot infer type argument for ability-constrained parameter - **E620**: Function dropped: skipped callee or no function table +- **E621**: Name collision: module ADT contends with a prelude data type - **E699**: Internal compiler error - **E700**: Contract violation during testing - **E701**: Cannot generate test inputs diff --git a/scripts/check_conformance.py b/scripts/check_conformance.py index 679c7f0a..f4c420e1 100644 --- a/scripts/check_conformance.py +++ b/scripts/check_conformance.py @@ -46,16 +46,20 @@ def main() -> int: level = entry["level"] level_n = _LEVEL_ORDER.get(level, 0) - # Negative test: the program must FAIL at its level with a specific - # error code (e.g. ch08_circular_import → E011). Only `check`-level - # negatives are supported (the diagnostic fires during check); assert - # `ok == false` and the expected code is present, then skip the - # positive pipeline for this entry. + # Negative test: the program must FAIL with a specific error code + # (e.g. ch08_circular_import → E011). `expected_error_stage` names + # the pipeline stage the diagnostic fires at — "check" (the default) + # or "compile". A COMPILE-stage negative additionally asserts that + # `check` is clean, which is the whole point of the class it exists + # to pin: a program the checker accepts and codegen must refuse + # (#1277's E621). Either way the positive pipeline is skipped. expected_error = entry.get("expected_error") if expected_error is not None: - # The diagnostic fires during check, so a negative entry must be - # declared at level "check". Fail fast on a mislabelled entry so a - # verify/run negative can't silently skip its declared stage. + stage = entry.get("expected_error_stage", "check") + # A negative's positive obligation stops at check, so it is + # declared at level "check" whichever stage it fails at. Fail + # fast on a mislabelled entry so a verify/run negative can't + # silently skip its declared stage. if level != "check": failed.append(( entry_id, "manifest", @@ -63,12 +67,28 @@ def main() -> int: f"got level={level!r}", )) continue - result = _vera("check", "--json", path) + if stage not in ("check", "compile"): + failed.append(( + entry_id, "manifest", + f"expected_error_stage must be 'check' or 'compile'; " + f"got {stage!r}", + )) + continue + if stage == "compile": + pre = _vera("check", path) + if "OK:" not in pre.stdout: + failed.append(( + entry_id, "check (compile-stage negative)", + "a compile-stage negative must type-check cleanly:\n" + + pre.stdout + pre.stderr, + )) + continue + result = _vera(stage, "--json", path) try: payload = json.loads(result.stdout) except json.JSONDecodeError: failed.append(( - entry_id, "check (negative)", + entry_id, f"{stage} (negative)", "expected JSON diagnostics, got:\n" + result.stdout + result.stderr, )) @@ -76,7 +96,7 @@ def main() -> int: codes = [d.get("error_code") for d in payload.get("diagnostics", [])] if payload.get("ok") is not False or expected_error not in codes: failed.append(( - entry_id, "check (negative)", + entry_id, f"{stage} (negative)", f"expected failure with {expected_error}; " f"got ok={payload.get('ok')} codes={codes}", )) diff --git a/spec/08-modules.md b/spec/08-modules.md index 0a5b5001..1e97a9d0 100644 --- a/spec/08-modules.md +++ b/spec/08-modules.md @@ -106,7 +106,7 @@ private fn helper(@Int -> @Int) - `public` declarations are visible to any module that imports them. - `private` declarations are visible only within the module that defines them. -- Type aliases (`type Foo = ...`), effect declarations (`effect E { ... }`), module declarations, and import statements do not take visibility modifiers. These declarations are **module-local** — they are not importable by other modules. If another module needs the same type alias or effect, it must declare its own copy. The prelude's own combinators resolve their closure-parameter types through aliases a program cannot name: those aliases carry reserved names, and a name beginning with `Vera` followed by an uppercase letter or digit is a compile error (**E154**) — whether the program *declares* that name as a type, an alias, an effect, an ability or a constructor, *binds* it as a type parameter, or merely *mentions* it in a type. The reservation is one rule across every namespace, so the prelude's internal namespace can be neither re-typed, shadowed by a binder, nor referenced, and a program that wants a short name for a function type declares its own alias for it. Outside a type position there is no alias escape, so the fix in the effect, ability and constructor namespaces is simply a name that does not start with the reserved prefix. The prelude's data types (`Option`, `Result`, `Ordering`, `UrlParts`, …) are not in that namespace: they are ordinary public declarations a program names, and shadows, like any other. +- Type aliases (`type Foo = ...`), effect declarations (`effect E { ... }`), module declarations, and import statements do not take visibility modifiers. These declarations are **module-local** — they are not importable by other modules. If another module needs the same type alias or effect, it must declare its own copy. The prelude's own combinators resolve their closure-parameter types through aliases a program cannot name: those aliases carry reserved names, and a name beginning with `Vera` followed by an uppercase letter or digit is a compile error (**E154**) — whether the program *declares* that name as a type, an alias, an effect, an ability or a constructor, *binds* it as a type parameter, or merely *mentions* it in a type. The reservation is one rule across every namespace, so the prelude's internal namespace can be neither re-typed, shadowed by a binder, nor referenced, and a program that wants a short name for a function type declares its own alias for it. Outside a type position there is no alias escape, so the fix in the effect, ability and constructor namespaces is simply a name that does not start with the reserved prefix. The prelude's data types (`Option`, `Result`, `Ordering`, `UrlParts`, …) are not in that namespace: they are ordinary public declarations a program names, and shadows, like any other. A declaration in the **entry file** shadows the prelude's for the whole program: the prelude injects nothing under that name, so the entry's declaration never contends with the prelude's. That settles the pair it names and no other — where a *module* declares the same name as well, the entry's declaration and the module's are a distinct pair, which the compiler does not yet arbitrate ([#1312](https://github.com/aallan/vera/issues/1312)). A declaration in a **module** shadows it for that module alone only while the prelude is not also compiling its own declaration of that name — the two would otherwise contend for one layout in the flat compiled namespace (§11.16), and the compiler reports **E621** at the module's declaration. Whether they contend is decided by the two declarations' *shapes*: a module that restates the prelude's type — the same constructors, in the same order, with the same field types, type parameters compared by position — shares the one layout and is not a contention. A differently-shaped one is, and the condition differs between the two halves of the prelude's data types: for `Json`, `HtmlNode`, `Request` and `Response`, which the prelude injects only when the entry program uses them, the module's declaration stands alone until it does; for `Option`, `Result`, `Ordering` and `UrlParts`, which every program compiles, a differently-shaped module declaration always contends. - Functions declared inside `where` blocks are always local to the parent function and do not take visibility modifiers. ### 8.4.2 Data Type Visibility diff --git a/spec/11-compilation.md b/spec/11-compilation.md index e11905ee..6f081513 100644 --- a/spec/11-compilation.md +++ b/spec/11-compilation.md @@ -617,6 +617,10 @@ Imported functions are **not** exported from the WASM module — only the import **Name collision detection**: If two imported modules define a function (E608), data type (E609), or constructor (E610) with the same name, the compiler reports an error listing both modules. Rename the conflicting declaration in one of the source modules to resolve the collision. Qualified-call disambiguation via name mangling is planned for a future version. +An imported module's data type may collide with one the **prelude** provides in the same way, and the compiler reports **E621** at the module's declaration. The prelude's declarations are compiled into this same flat namespace, which holds one layout per name, so two declarations of a prelude name contend exactly when their *shapes* differ — different constructors, a different constructor order (the tag is the position), or different field types; type parameters are compared by position, so renaming one is not a difference. A module that restates the prelude's type shares the one layout and compiles. The diagnostic names the module and the type and offers both resolutions: rename it in the module, or give it the prelude's shape. + +Whether the prelude is compiling its own declaration of that name depends on which half of its data types the name belongs to. `Json`, `HtmlNode`, `Request` and `Response` are injected only when the entry program uses them, so a module's differently-shaped declaration stands alone until it does. `Option`, `Result`, `Ordering` and `UrlParts` are in every program, so a differently-shaped module declaration of one of those always contends. A declaration in the **entry file** suppresses the prelude's injection outright and so never contends with it (§8.4.1). + ## 11.17 Limitations The Tier-3 runtime guard for the `@Nat >= 0` narrowing invariant covers every concrete binding site, the function **return** position ([#758](https://github.com/aallan/vera/issues/758)), and generic function-formal calls (guarded on the monomorphised callee). Two binding sites stay unguarded — the effect-operation argument (guard deferred) and the generic-instantiated constructor field (constructor layouts carry no per-field `@Nat` mono metadata) — though both are still obligated statically (a negative is an E503/E504 at compile time). A tripped guard also reports a generic trap rather than the `requires(... >= 0)` fix. The effect-op-argument guard and the dedicated trap kind are tracked as [#754](https://github.com/aallan/vera/issues/754). diff --git a/tests/conformance/ch08_module_prelude_adt_contention_rejected.vera b/tests/conformance/ch08_module_prelude_adt_contention_rejected.vera new file mode 100644 index 00000000..1b7db9ca --- /dev/null +++ b/tests/conformance/ch08_module_prelude_adt_contention_rejected.vera @@ -0,0 +1,28 @@ +-- Conformance: a module data type contending with a prelude one is a codegen +-- error (Chapter 8, Section 8.4.1; Chapter 11, Section 11.16). +-- Tests: E621 -- `vera.shadowlib` declares `private data Json { JBlob(Int) }` +-- and this program uses the prelude's `Json`, so the two differently-shaped +-- declarations contend for the one flat layout slot. This program type-checks +-- CLEANLY and must then be refused by `vera compile`: before the rail the +-- module's layout won, the prelude's `Json` was never registered, and `depth` +-- vanished from the exports behind [E602]/[E620] warnings located in +-- `` -- a zero-exit compile of a module missing a function (#1277). +-- The paired positive is `ch08_module_prelude_adt_name.vera`, which imports +-- the same module and never names `Json`. +import vera.shadowlib(blob_size); + +public fn depth(@Json -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + json_array_length(@Json.0) +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + blob_size(7) +} diff --git a/tests/conformance/ch08_module_prelude_adt_name.vera b/tests/conformance/ch08_module_prelude_adt_name.vera new file mode 100644 index 00000000..d3614e34 --- /dev/null +++ b/tests/conformance/ch08_module_prelude_adt_name.vera @@ -0,0 +1,21 @@ +-- Conformance: an imported module may declare a data type named after one of +-- the prelude's (Chapter 8, Section 8.4.1). +-- Tests: `vera.shadowlib` declares `private data Json`, and nothing in this +-- program demands the prelude's `Json`, so the prelude injects nothing under +-- that name and the module's own type is the only one -- its constructor +-- stays reachable from the module's own bodies and the program runs (#1277). +-- The negative half of the pair is +-- `ch08_module_prelude_adt_contention_rejected.vera`, which imports this same +-- module and DOES use the prelude's `Json`: the two declarations then contend +-- for the one flat layout slot and the compile is refused with E621. Between +-- them they pin both directions, so silencing the rail turns the negative red +-- and widening it turns this one red. +import vera.shadowlib(blob_size); + +public fn main(@Unit -> @Int) + requires(true) + ensures(@Int.result == 7) + effects(pure) +{ + blob_size(7) +} diff --git a/tests/conformance/ch08_prelude_adt_name_alias.vera b/tests/conformance/ch08_prelude_adt_name_alias.vera new file mode 100644 index 00000000..c002246b --- /dev/null +++ b/tests/conformance/ch08_prelude_adt_name_alias.vera @@ -0,0 +1,41 @@ +-- Conformance: a type alias may reuse a prelude data type's name +-- (Chapter 8, Section 8.4.1). +-- Tests: the prelude's data types are ordinary public declarations a program +-- names and shadows -- the reserved namespace is the `Vera` prefix alone +-- (E154) -- so `type UrlParts = Int;` is accepted, and only a user `data` of +-- that name suppresses the prelude's own declaration. Both therefore exist +-- here: the alias resolves inside the type argument below, and the prelude's +-- `UrlParts` is still injected, still stamped, and its other declarations are +-- unaffected -- `Option` is matched as usual. +-- What this program does NOT pin is #1287 itself. The defect was an internal +-- one -- the prelude's declaration-index block losing an entry -- and it is +-- inert at emission, because `AliasEnv.data_types` changes a rendering only +-- for `Decimal` and `Float`. The emitted WAT is identical either way, so no +-- run-level program can distinguish it; the unit cells in +-- `tests/test_prelude_decl_stamp_1287.py` carry that claim. What this pins is +-- that the shape is accepted, verifies and runs. +-- The alias is deliberately used in a type-ARGUMENT position rather than as a +-- slot head: a slot headed by an alias sharing a registered ADT's name is +-- separately miscompiled (#1309), which is not what this program is about. +import vera.math(magnitude); + +type UrlParts = Int; + +public fn total(@Array -> @Int) + requires(true) + ensures(@Int.result >= 0) + effects(pure) +{ + magnitude(array_length(@Array.0)) +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + match array_find([3, 4], fn(@Int -> @Bool) effects(pure) { @Int.0 > 3 }) { + Some(@Int) -> total([@Int.0]), + None -> 0 + } +} diff --git a/tests/conformance/manifest.json b/tests/conformance/manifest.json index bd14eb46..1c052d4d 100644 --- a/tests/conformance/manifest.json +++ b/tests/conformance/manifest.json @@ -1202,7 +1202,7 @@ "id": "ch07_state_clause_transform", "file": "ch07_state_clause_transform.vera", "chapter": 7, - "title": "State clause bodies execute — intrinsic-hybrid semantics (#976)", + "title": "State clause bodies execute \u2014 intrinsic-hybrid semantics (#976)", "level": "run", "spec_ref": "Section 7.5.2", "features": [ @@ -1261,9 +1261,9 @@ "id": "ch07_exn_string", "file": "ch07_exn_string.vera", "chapter": 7, - "title": "Exn effect handler — String payload", + "title": "Exn effect handler \u2014 String payload", "level": "run", - "spec_ref": "Section 7.4 Exn handler — String payload", + "spec_ref": "Section 7.4 Exn handler \u2014 String payload", "features": [ "exn_handler", "string_exception" @@ -1696,7 +1696,7 @@ "id": "ch08_transitive_module_import_base", "file": "ch08_transitive_module_import_base.vera", "chapter": 8, - "title": "Transitive import chain — base module", + "title": "Transitive import chain \u2014 base module", "level": "check", "spec_ref": "Section 8.6.4", "features": [ @@ -1708,7 +1708,7 @@ "id": "ch08_transitive_module_import_mid", "file": "ch08_transitive_module_import_mid.vera", "chapter": 8, - "title": "Transitive import chain — middle module", + "title": "Transitive import chain \u2014 middle module", "level": "verify", "spec_ref": "Section 8.6.4", "features": [ @@ -3249,5 +3249,49 @@ "transitive_import", "bare_call" ] + }, + { + "id": "ch08_prelude_adt_name_alias", + "file": "ch08_prelude_adt_name_alias.vera", + "chapter": 8, + "title": "A type alias may reuse a prelude data type's name", + "level": "run", + "spec_ref": "Section 8.4.1", + "features": [ + "type_alias", + "prelude_shadowing", + "cross_module", + "declaration_order" + ] + }, + { + "id": "ch08_module_prelude_adt_name", + "file": "ch08_module_prelude_adt_name.vera", + "chapter": 8, + "title": "A module may declare a data type named after a prelude one", + "level": "run", + "spec_ref": "Section 8.4.1", + "features": [ + "cross_module", + "prelude_shadowing", + "adt", + "visibility_private" + ] + }, + { + "id": "ch08_module_prelude_adt_contention_rejected", + "file": "ch08_module_prelude_adt_contention_rejected.vera", + "chapter": 8, + "title": "A module data type contending with a prelude one is a codegen error (#1277)", + "level": "check", + "spec_ref": "Section 8.4.1", + "expected_error": "E621", + "expected_error_stage": "compile", + "features": [ + "cross_module", + "prelude_shadowing", + "adt", + "codegen_error" + ] } ] diff --git a/tests/conformance/vera/shadowlib.vera b/tests/conformance/vera/shadowlib.vera new file mode 100644 index 00000000..3e3bc8c3 --- /dev/null +++ b/tests/conformance/vera/shadowlib.vera @@ -0,0 +1,20 @@ +module vera.shadowlib; + +-- A module-local data type whose name is one the prelude also provides. +-- Spec 8.4.1: the prelude's data types are ordinary public declarations a +-- program names and shadows -- the reserved namespace is the `Vera` prefix +-- alone (E154) -- so this declaration is legal, and it stays legal for as +-- long as nothing in the program demands the prelude's `Json` (#1277). +private data Json { + JBlob(Int) +} + +public fn blob_size(@Int -> @Int) + requires(true) + ensures(@Int.result == @Int.0) + effects(pure) +{ + match JBlob(@Int.0) { + JBlob(@Int) -> @Int.0 + } +} diff --git a/tests/test_conformance.py b/tests/test_conformance.py index bbeee387..0f7de6f8 100644 --- a/tests/test_conformance.py +++ b/tests/test_conformance.py @@ -65,26 +65,45 @@ def test_parse(self, entry: dict) -> None: def test_check(self, entry: dict) -> None: """Programs at level check/verify/run must type-check cleanly — or, - for a negative entry (``expected_error``), must FAIL check with that - error code (e.g. ch08_circular_import → E011).""" + for a negative entry (``expected_error``), must FAIL at the stage + ``expected_error_stage`` names with that error code + (ch08_circular_import → E011 at check; + ch08_module_prelude_adt_contention_rejected → E621 at compile). + + A COMPILE-stage negative also asserts that check is clean, because + "the checker accepts it and codegen must refuse it" is exactly the + property that class of diagnostic exists for — a negative that + started failing at check would otherwise still pass.""" if not _at_least(entry, "check"): pytest.skip("parse-only") path = str(CONFORMANCE_DIR / entry["file"]) expected_error = entry.get("expected_error") if expected_error is not None: - # The diagnostic fires during check, so a negative entry must be - # declared at level "check"; a verify/run negative would otherwise - # silently skip its declared stage. Fail fast on a mislabel. + # A negative's positive obligation stops at check, so it is + # declared at level "check" whichever stage it fails at; a + # verify/run negative would otherwise silently skip its declared + # stage. Fail fast on a mislabel. assert entry["level"] == "check", ( f"expected_error is only valid at level 'check'; " f"{entry['id']} is level {entry['level']!r}" ) - result = _vera("check", "--json", path) + stage = entry.get("expected_error_stage", "check") + assert stage in ("check", "compile"), ( + f"{entry['id']}: expected_error_stage must be 'check' or " + f"'compile'; got {stage!r}" + ) + if stage == "compile": + pre = _vera("check", path) + assert "OK:" in pre.stdout, ( + f"{entry['id']} is a compile-stage negative, so it must " + f"type-check cleanly:\n{pre.stdout}\n{pre.stderr}" + ) + result = _vera(stage, "--json", path) payload = json.loads(result.stdout) codes = [d.get("error_code") for d in payload.get("diagnostics", [])] assert payload.get("ok") is False and expected_error in codes, ( - f"Expected {entry['id']} to fail check with " + f"Expected {entry['id']} to fail {stage} with " f"{expected_error}; got ok={payload.get('ok')} " f"codes={codes}\n{result.stdout}" ) diff --git a/tests/test_prelude_adt_namespace_1277.py b/tests/test_prelude_adt_namespace_1277.py new file mode 100644 index 00000000..10ea5803 --- /dev/null +++ b/tests/test_prelude_adt_namespace_1277.py @@ -0,0 +1,808 @@ +"""#1277: a module's ADT name must not evict the prelude's from other scopes. + +Two defects with one root — codegen keeps ONE flat `_adt_layouts` map and +ONE flat constructor namespace, while the checker gives every namespace +the prelude's data types from the start (`vera/environment.py` registers +`Option`, `Result`, `Ordering`, `UrlParts`, `Json`, `HtmlNode`, +`Request` and `Response` in every `TypeEnv`, unconditionally). + +**Membership.** `_adt_members_in_scope` recovers global infrastructure by +SUBTRACTING what the namespaces declare from the registered layouts. The +subtraction is only sound while "declared by a namespace" and "global +infrastructure" are disjoint, and §8.4.1 makes them overlap on purpose: +the prelude's data types are ordinary public declarations a program names +and shadows. So one file's `data Json` removed `Json` from every OTHER +namespace's member set. The Pass-0.5 built-in snapshot unioned in as a +floor does not cover the four demand-injected prelude ADTs, because it is +taken before Pass 1.2 injects them. + +**Contention.** When the declaration is a MODULE's and the entry program +uses the prelude's type of that name, the two contend for the one layout +slot and the module wins: the prelude's own ADT is never registered, its +combinators hit `unknown constructor`, and every function that touches +the type is dropped — with the diagnostic pointing into `` and +nothing naming the declaration that caused it. + +Membership is asserted on `AliasEnv.data_types` / the member set itself +rather than on a rendering, for the reason +`test_adt_membership_scope_1253` states: that map changes an answer in +exactly one place (`naming._resolve_named`) and only for `Decimal` and +the single `REMOVED_ALIASES` entry `Float`, so no rendering can +distinguish a missing `Json`. What is guarded is the set being +FACTUALLY right, against the checker's own answer as the oracle. + +The two fixes are pinned separately and by different cases, so a +regression in either is attributable: the contention rail by the E621 +cases, the membership floor by the two cases that never reach it. +""" + +from __future__ import annotations + +from dataclasses import replace +from pathlib import Path + +import pytest + +from vera import naming +from vera.checker.core import TypeChecker +from vera.codegen.core import CodeGenerator +from vera.errors import ERROR_CODES +from vera.parser import parse_file +from vera.prelude import inject_prelude +from vera.resolver import ModuleResolver +from vera.transform import transform + +# `prelude_adt_names` is imported inside the one case that needs it, not +# at module scope: every other case here states a RED/GREEN claim about +# the state BEFORE this fix, and a module-level import of a symbol the +# fix introduces would make them un-runnable at that baseline (the +# collection error, not the assertion, would be the result). Same +# discipline as `test_adt_membership_scope_1253`. + +# --------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------- + +# A module-local `data Json` whose constructor is deliberately NOT one of +# the prelude's six, so "which layout is registered under `Json`" is +# answerable from the constructor names alone. +_JLIB_OWN_JSON = """ +private data Json { + JBlob(Int) +} + +public fn blob_size(@Int -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + match JBlob(@Int.0) { + JBlob(@Int) -> @Int.0 + } +} +""" + +# The entry program uses the PRELUDE's `Json` — `inject_prelude` is +# demand-driven off this file, so naming the type here is what makes the +# prelude inject `data Json` at all. +_MAIN_USES_PRELUDE_JSON = """ +import jlib(blob_size); + +public fn depth(@Json -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + json_array_length(@Json.0) +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + blob_size(7) +} +""" + +# The control for the rail: the same module declaration, and an entry that +# never names `Json`. Nothing is injected, nothing contends, and §8.4.1 +# says this must keep working. +_MAIN_IGNORES_JSON = """ +import jlib(blob_size); + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + blob_size(7) +} +""" + +_PLIB_USES_JSON = """ +public fn tally(@Json, @Int -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + @Int.0 +} +""" + +# The sanctioned §8.4.1 shadow: the ENTRY file declares `data Json`, so +# `inject_prelude` skips its own and one declaration serves the program. +_MAIN_DECLARES_JSON = """ +import plib(tally); + +public data Json { + JNull, + JBool(Bool), + JNumber(Float64), + JString(String), + JArray(Array), + JObject(Map) +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + tally(JNull, 3) +} +""" + + +def _compile(tmp_path: Path, files: dict[str, str]) -> CodeGenerator: + """Write *files*, compile ``main.vera``, hand back the generator.""" + tmp_path.mkdir(parents=True, exist_ok=True) + for name, text in files.items(): + (tmp_path / name).write_text(text, encoding="utf-8") + main_path = tmp_path / "main.vera" + program = transform(parse_file(str(main_path))) + mods = ModuleResolver(tmp_path).resolve_imports(program, main_path) + gen = CodeGenerator( + source=main_path.read_text(encoding="utf-8"), file=str(main_path), + ) + gen._resolved_modules = mods + gen._result = gen.compile_program(program) # type: ignore[attr-defined] + return gen + + +def _checker_data_types( + tmp_path: Path, module: str, +) -> frozenset[str]: + """The names the CHECKER treats as data types inside *module*. + + The oracle for every membership claim below. Built the way + `_collect_module_artifacts` has since #987 — the module checked as + ITSELF, with `direct` re-derived against it, because §8.6.4 + visibility is the importer's property. + """ + main_path = tmp_path / "main.vera" + program = transform(parse_file(str(main_path))) + mods = ModuleResolver(tmp_path).resolve_imports(program, main_path) + mod = next(m for m in mods if m.path == (module,)) + mod_direct = {imp.path for imp in mod.program.imports} + scoped = TypeChecker( + source=mod.source, file=str(mod.file_path), + resolved_modules=[ + replace(other, direct=other.path in mod_direct) + for other in mods if other.path != mod.path + ], + ) + scoped.check_program(mod.program) + return frozenset(naming.alias_env_from_environment(scoped.env).data_types) + + +# --------------------------------------------------------------------- +# The contention rail (E621) +# --------------------------------------------------------------------- + +def test_module_adt_contending_with_a_demanded_prelude_adt_is_loud( + tmp_path: Path, +) -> None: + """The dropped-everything shape reports at the declaration that caused it. + + At the branch point this program is `vera check`-green and then + compiles with only WARNINGS — an E602 for `unknown constructor + 'JNull'` inside the prelude's own combinator and an E620 cascade + behind it, every one of them located in `` — while `depth` + silently vanishes from the exports. The assertions below are the + three things that were wrong: the severity, the file, and whether + anything names `Json`. + """ + gen = _compile( + tmp_path, + {"jlib.vera": _JLIB_OWN_JSON, "main.vera": _MAIN_USES_PRELUDE_JSON}, + ) + result = gen._result # type: ignore[attr-defined] + errors = [d for d in result.diagnostics if d.severity == "error"] + assert len(errors) == 1, [ + (d.severity, d.error_code, d.description) for d in result.diagnostics + ] + err = errors[0] + assert err.error_code == "E621", err.error_code + # It must point at the USER's declaration, in the module's own file — + # `jlib.vera` line 2, where `private data Json` is written. + assert Path(err.location.file).name == "jlib.vera", err.location.file + assert err.location.line == 2, (err.location.line, err.source_line) + assert "data Json" in err.source_line, err.source_line + assert "Json" in err.description and "jlib" in err.description, ( + err.description) + assert err.fix and err.rationale and err.spec_ref + # And it refuses to emit rather than emitting a module missing `depth`. + assert result.exports == [], result.exports + + +def test_the_rail_makes_the_cli_fail(tmp_path: Path) -> None: + """`vera compile` exits non-zero on the contention shape. + + The whole point of the rail is the exit code: at the branch point + every diagnostic in this program is a warning, so `cmd_compile` + returned 0 over a module with the function silently missing. + """ + from vera.cli import cmd_check, cmd_compile + + tmp_path.mkdir(parents=True, exist_ok=True) + (tmp_path / "jlib.vera").write_text(_JLIB_OWN_JSON, encoding="utf-8") + main_path = tmp_path / "main.vera" + main_path.write_text(_MAIN_USES_PRELUDE_JSON, encoding="utf-8") + # `vera check` stays green: this is a codegen-namespace collision, not + # a type error, and the checker gives both namespaces their own view. + assert cmd_check(str(main_path), quiet=True) == 0 + assert cmd_compile(str(main_path), wat=True) == 1 + + +def test_a_module_owning_its_prelude_named_adt_alone_still_compiles( + tmp_path: Path, +) -> None: + """Within-namespace shadowing is untouched — §8.4.1 permits it. + + Green before and after. This is what separates "refuse a contention" + from "reserve the prelude's names", which the spec forbids: nothing + demands the prelude's `Json` here, so nothing contends, and the + module's own type must keep working. + """ + gen = _compile( + tmp_path, + {"jlib.vera": _JLIB_OWN_JSON, "main.vera": _MAIN_IGNORES_JSON}, + ) + result = gen._result # type: ignore[attr-defined] + assert [d for d in result.diagnostics if d.severity == "error"] == [] + assert result.exports == ["main"], result.exports + # The module's own layout is the registered one, unchallenged. + assert sorted(gen._adt_layouts["Json"]) == ["JBlob"] + + +def test_an_entry_file_shadow_is_not_a_contention(tmp_path: Path) -> None: + """The entry file's own `data Json` serves the whole program. + + Green before and after — the second half of the same separation. + `inject_prelude` skips its `data Json` here, so there is one + declaration and one layout, and the rail must not fire. + """ + gen = _compile( + tmp_path, + {"plib.vera": _PLIB_USES_JSON, "main.vera": _MAIN_DECLARES_JSON}, + ) + result = gen._result # type: ignore[attr-defined] + assert [d for d in result.diagnostics if d.severity == "error"] == [] + assert result.exports == ["main"], result.exports + + +def test_e621_is_registered(tmp_path: Path) -> None: + """The code the rail emits exists in the registry `vera errors` reads.""" + assert "E621" in ERROR_CODES + assert ERROR_CODES["E621"] + + +# --------------------------------------------------------------------- +# The membership floor +# --------------------------------------------------------------------- + +def test_prelude_adt_stays_a_member_of_a_module_namespace( + tmp_path: Path, +) -> None: + """One file's `data Json` must not empty `Json` out of another namespace. + + A check-green, compile-green, error-free program: the ENTRY declares + `data Json` (the sanctioned §8.4.1 shadow) and module `plib` takes a + `@Json` parameter. The checker gives `plib` the type; at the branch + point codegen did not, because the entry's declaration was subtracted + from the infrastructure set of every namespace including `plib`'s. + + Stated as a differential against the checker rather than against a + literal, so "both sides agree on the wrong answer" cannot satisfy it. + """ + gen = _compile( + tmp_path, + {"plib.vera": _PLIB_USES_JSON, "main.vera": _MAIN_DECLARES_JSON}, + ) + result = gen._result # type: ignore[attr-defined] + assert [d for d in result.diagnostics if d.severity == "error"] == [] + # There IS a layout to be a member of — otherwise the claim is vacuous. + assert "Json" in gen._adt_layouts + checker = _checker_data_types(tmp_path, "plib") + assert "Json" in checker, "the checker's own answer moved" + with gen._module_alias_scope(("plib",)): + codegen = frozenset(gen._alias_env.data_types) + assert "Json" in codegen, ( + f"checker sees Json in plib's namespace, codegen does not: " + f"{sorted(codegen)}" + ) + + +def test_entry_namespace_keeps_the_prelude_adt_a_module_declares( + tmp_path: Path, +) -> None: + """The issue's measured shape: `members[None]` must not lose `Json`. + + Pinned independently of the E621 rail that now also refuses this + program, so the membership rule is guarded on its own terms: if the + rail is ever narrowed, the entry program's namespace still holds the + prelude type it legitimately sees. Read off `_adt_members_in_scope`, + which is the set every consumer's `data_types` is filtered through. + """ + gen = _compile( + tmp_path, + {"jlib.vera": _JLIB_OWN_JSON, "main.vera": _MAIN_USES_PRELUDE_JSON}, + ) + assert gen._active_module_path is None + members = gen._adt_members_in_scope() + assert members is not None, "no module structure — fixture is wrong" + # The declaration IS in the subtracted set: that is the mechanism, and + # pinning it here keeps the case honest if the bookkeeping is renamed. + assert "Json" in gen._namespace_declared_adts + assert "Json" in members, ( + f"the entry namespace lost the prelude's Json because module jlib " + f"declared that name; members = {sorted(members)}" + ) + + +# --------------------------------------------------------------------- +# The rail covers all EIGHT prelude ADTs, and only real contentions +# --------------------------------------------------------------------- + +_PRELUDE_ADTS = ( + "Option", "Result", "Ordering", "UrlParts", + "Json", "HtmlNode", "Request", "Response", +) + +# Entry bodies that use the PRELUDE's type of each name. Written out +# rather than generated, because a generated body that failed to type-check +# would make a cell vacuous instead of failing. +_ENTRY_USE: dict[str, tuple[str, str]] = { + "Option": ("@Option", + "match @Option.0 {\n Some(@Int) -> @Int.0,\n" + " None -> 0\n }"), + "Result": ("@Result", + "match @Result.0 {\n Ok(@Int) -> @Int.0,\n" + " Err(@Int) -> 0\n }"), + "Ordering": ("@Ordering", + "match @Ordering.0 {\n Less -> 0,\n Equal -> 1,\n" + " Greater -> 2\n }"), + "UrlParts": ("@UrlParts", + "match @UrlParts.0 {\n UrlParts(@String, @String," + " @String, @String, @String) -> string_length(@String.0)\n" + " }"), + "Json": ("@Json", "json_array_length(@Json.0)"), + "HtmlNode": ("@HtmlNode", + "match @HtmlNode.0 {\n HtmlElement(@String," + " @Map, @Array) -> 1,\n" + " HtmlText(@String) -> 2,\n" + " HtmlComment(@String) -> 3\n }"), + "Request": ("@Request", + "match @Request.0 {\n Request(@String, @String," + " @Map, @String) -> string_length(@String.0)\n" + " }"), + "Response": ("@Response", + "match @Response.0 {\n Response(@Int," + " @Map, @String) -> @Int.0\n }"), +} + +# A module declaration of each name that RESTATES the prelude's own shape. +# These are the legal shapes the rail must not fire on: one registered +# layout serves both declarations, which is why they compile and run. +_IDENTICAL_DECL: dict[str, str] = { + "Option": "private data Option {\n None,\n Some(T)\n}", + "Result": "private data Result {\n Ok(T),\n Err(E)\n}", + "Ordering": "private data Ordering {\n Less,\n Equal,\n Greater\n}", + "UrlParts": ("private data UrlParts {\n UrlParts(String, String," + " String, String, String)\n}"), + "Json": ("private data Json {\n JNull,\n JBool(Bool),\n" + " JNumber(Float64),\n JString(String),\n" + " JArray(Array),\n JObject(Map)\n}"), + "HtmlNode": ("private data HtmlNode {\n HtmlElement(String," + " Map, Array),\n" + " HtmlText(String),\n HtmlComment(String)\n}"), + "Request": ("private data Request {\n Request(String, String," + " Map, String)\n}"), + "Response": ("private data Response {\n Response(Int," + " Map, String)\n}"), +} + + +def _blib(name: str, *, identical: bool) -> str: + decl = ( + _IDENTICAL_DECL[name] if identical + else f"private data {name} {{\n B{name}(Int)\n}}" + ) + return f"""{decl} + +public fn probe(@Int -> @Int) + requires(true) + ensures(true) + effects(pure) +{{ + @Int.0 +}} +""" + + +def _entry(name: str, *, demands: bool) -> str: + head = "import blib(probe);\n" + tail = """ +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + probe(7) +} +""" + if not demands: + return head + tail + slot, body = _ENTRY_USE[name] + return head + f""" +public fn consume({slot} -> @Int) + requires(true) + ensures(true) + effects(pure) +{{ + {body} +}} +""" + tail + + +def _acceptance_cell( + tmp_path: Path, name: str, *, demands: bool, identical: bool = False, +) -> tuple[list[str], list[str]]: + """(diagnostic codes, exports) for one battery cell.""" + gen = _compile(tmp_path, { + "blib.vera": _blib(name, identical=identical), + "main.vera": _entry(name, demands=demands), + }) + result = gen._result # type: ignore[attr-defined] + return ( + sorted({d.error_code for d in result.diagnostics if d.error_code}), + sorted(result.exports), + ) + + +@pytest.mark.parametrize("name", _PRELUDE_ADTS) +@pytest.mark.parametrize("demands", [False, True], ids=["alone", "demands"]) +def test_every_cell_is_clean_or_one_e621( + tmp_path: Path, name: str, demands: bool, +) -> None: + """Every module-declares cell: clean-green, or a single E621. Never noise. + + The acceptance battery. At the branch point every one of these + sixteen cells was `vera check`-green and then either an E602/E620 + cascade located in `` or — worse — a zero-exit compile with + a user function silently missing from the exports. Two properties + are asserted, and neither hard-codes which names fall where, so the + four-vs-four coverage split the rail started with cannot return + silently: + + * no cell reports E602 or E620 — the wreckage diagnostics are gone, + not merely joined by a better one; + * a cell that reports E621 emits nothing, and a cell that does not + emits every public function the entry declares. That is the + silent-drop check: a zero-exit compile missing a function fails + here whichever name produced it. + """ + codes, exports = _acceptance_cell(tmp_path, name, demands=demands) + assert set(codes) <= {"E621"}, ( + f"{name} ({'demands' if demands else 'alone'}): expected at most " + f"E621, got {codes}" + ) + expected = ["consume", "main"] if demands else ["main"] + if codes == ["E621"]: + assert exports == [], f"{name}: refused, yet emitted {exports}" + else: + assert exports == expected, ( + f"{name}: zero-exit compile dropped " + f"{sorted(set(expected) - set(exports))}" + ) + + +@pytest.mark.parametrize("name", _PRELUDE_ADTS) +def test_a_differing_module_declaration_contends_for_every_name( + tmp_path: Path, name: str, +) -> None: + """All EIGHT, not the four the layout map happens to record. + + `_register_modules` skips a built-in ADT name in the layout harvest + (the throwaway registrar holds `Option`, `Result`, `Ordering` and + `UrlParts` for every module, declared or not, so the layouts cannot + tell a declaration from the built-in), which left + `_adt_layout_owners` recording only `Json`, `HtmlNode`, `Request` + and `Response`. Keying the rail on it covered four of the eight + names while §8.4.1 and §11.16 claim all of them. The rail now reads + the DECLARATIONS. + """ + codes, exports = _acceptance_cell(tmp_path, name, demands=True) + assert codes == ["E621"], f"{name}: {codes}" + assert exports == [] + + +@pytest.mark.parametrize("name", _PRELUDE_ADTS) +def test_restating_the_prelude_shape_is_not_a_contention( + tmp_path: Path, name: str, +) -> None: + """A module may restate the prelude's own type — measured legal, kept legal. + + One registered layout is correct for both declarations, so these + programs compile and run at the branch point and must keep doing so. + This is what stops the rail from becoming the reservation §8.4.1 + forbids, and it is a real regression guard rather than a hypothetical + twice over: the rail's first form refused four of these, and the shape + ships in the repository — `examples/vera/collections.vera` declares + `public data Option { None, Some(T) }`, which `examples/modules.vera` + imports, so a rail without the structural test refuses a shipped + example (measured: `vera compile` on `examples/modules.vera` returns + E621, which `scripts/check_e602_clean.py` reports as a COMPILE_ERROR — + `check_examples.py` runs only `check` and `verify` and stays green). + """ + codes, exports = _acceptance_cell( + tmp_path, name, demands=True, identical=True) + assert codes == [], f"{name}: restating the prelude's shape reported {codes}" + assert exports == ["consume", "main"], exports + + +# --------------------------------------------------------------------- +# TWO declaring modules — the rail must examine every one of them +# --------------------------------------------------------------------- + +_ORD_RESTATE = "private data Ordering {\n Less,\n Equal,\n Greater\n}" +_ORD_DIFFER = "private data Ordering {\n Odd(Int)\n}" +_ORD_DIFFER2 = "private data Ordering {\n Even(Int)\n}" +_WIDGET_A = "private data Widget {\n WA(Int)\n}" +_WIDGET_B = "private data Widget {\n WB(Int)\n}" + +_DECL_BODY = { + _ORD_RESTATE: "match Less {\n Less -> @Int.0,\n Equal -> 1,\n" + " Greater -> 2\n }", + _ORD_DIFFER: "match Odd(@Int.0) {\n Odd(@Int) -> @Int.0\n }", + _ORD_DIFFER2: "match Even(@Int.0) {\n Even(@Int) -> @Int.0\n }", + _WIDGET_A: "match WA(@Int.0) {\n WA(@Int) -> @Int.0\n }", + _WIDGET_B: "match WB(@Int.0) {\n WB(@Int) -> @Int.0\n }", +} + + +def _two_module_cell( + tmp_path: Path, decl_a: str, decl_b: str, *, + a_first: bool, uses_ordering: bool = True, +) -> tuple[list[str], list[str]]: + """(codes, exports) for two modules that each declare one name.""" + def lib(fn: str, decl: str) -> str: + return f"""{decl} + +public fn {fn}(@Int -> @Int) + requires(true) + ensures(true) + effects(pure) +{{ + {_DECL_BODY[decl]} +}} +""" + first, second = ("alib", "blib") if a_first else ("blib", "alib") + fn1, fn2 = ("afn", "bfn") if a_first else ("bfn", "afn") + consume = """ +public fn consume(@Ordering -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + match @Ordering.0 { + Less -> 0, + Equal -> 1, + Greater -> 2 + } +} +""" if uses_ordering else "" + entry = f"""import {first}({fn1}); +import {second}({fn2}); +{consume} +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{{ + {fn1}(1) + {fn2}(2) +}} +""" + gen = _compile(tmp_path, { + "alib.vera": lib("afn", decl_a), + "blib.vera": lib("bfn", decl_b), + "main.vera": entry, + }) + result = gen._result # type: ignore[attr-defined] + return ( + sorted({d.error_code for d in result.diagnostics if d.error_code}), + sorted(result.exports), + ) + + +@pytest.mark.parametrize("a_first", [True, False], ids=["alib1st", "blib1st"]) +def test_a_second_module_declaration_is_examined_too( + tmp_path: Path, a_first: bool, +) -> None: + """The rail asks EVERY declaring module, not whichever declared first. + + `alib` restates the prelude's `Ordering` and `blib` declares a + different one. Keyed on a first-wins owner map the rail compared the + prelude against `alib`'s identical shape, found no contention, and + never looked at `blib` — so with `alib` imported first the program was + `vera check`-green, compiled with exit 0 and only `[E602]`/`[E620]` + warnings, and `main` was silently missing from the exports; with the + imports the other way round the same pair was caught. An + order-dependent rail is not a rail. + + E609 cannot cover this either: the layout harvest exempts a built-in + name before the provenance check, so two modules declaring `Ordering` + never reach it. + """ + codes, exports = _two_module_cell( + tmp_path, _ORD_RESTATE, _ORD_DIFFER, a_first=a_first) + assert codes == ["E621"], ( + f"{'alib' if a_first else 'blib'} first: expected the differing " + f"declaration to be reported, got {codes} with exports {exports}" + ) + assert exports == [] + + +@pytest.mark.parametrize("a_first", [True, False], ids=["alib1st", "blib1st"]) +def test_two_differing_module_declarations_are_both_reported( + tmp_path: Path, a_first: bool, +) -> None: + """Each differing declaration is its own problem, and gets its own report.""" + gen = _compile(tmp_path, { + "alib.vera": f"""{_ORD_DIFFER} + +public fn afn(@Int -> @Int) + requires(true) + ensures(true) + effects(pure) +{{ + {_DECL_BODY[_ORD_DIFFER]} +}} +""", + "blib.vera": f"""{_ORD_DIFFER2} + +public fn bfn(@Int -> @Int) + requires(true) + ensures(true) + effects(pure) +{{ + {_DECL_BODY[_ORD_DIFFER2]} +}} +""", + "main.vera": """import alib(afn); +import blib(bfn); + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + afn(1) + bfn(2) +} +""", + }) + result = gen._result # type: ignore[attr-defined] + e621 = [d for d in result.diagnostics if d.error_code == "E621"] + assert len(e621) == 2, [ + (d.error_code, d.location.file) for d in result.diagnostics] + assert {Path(d.location.file).name for d in e621} == { + "alib.vera", "blib.vera"}, [d.location.file for d in e621] + assert sorted(result.exports) == [] + del a_first # the assertion is order-independent by construction + + +@pytest.mark.parametrize("a_first", [True, False], ids=["alib1st", "blib1st"]) +def test_two_modules_both_restating_the_prelude_are_legal( + tmp_path: Path, a_first: bool, +) -> None: + """Two restatements share the one layout — green before and after.""" + codes, exports = _two_module_cell( + tmp_path, _ORD_RESTATE, _ORD_RESTATE, a_first=a_first) + assert codes == [], codes + assert exports == ["consume", "main"], exports + + +@pytest.mark.parametrize("a_first", [True, False], ids=["alib1st", "blib1st"]) +def test_two_modules_declaring_a_NON_prelude_name_stay_e609( + tmp_path: Path, a_first: bool, +) -> None: + """The module-versus-module pair is E609's, and this rail leaves it there. + + The control that keeps the two rails apart: `Widget` is nobody's + prelude type, so no prelude declaration is injected, nothing reaches + the Pass-1.2 rail, and the existing collision rail reports it. + """ + codes, exports = _two_module_cell( + tmp_path, _WIDGET_A, _WIDGET_B, a_first=a_first, uses_ordering=False) + assert codes == ["E609"], codes + assert exports == [] + + +def test_the_shape_key_ignores_parameter_names_and_not_tag_order() -> None: + """`data_decl_shape` models the layout: positions, not spellings. + + Renaming a type parameter changes no layout, so `data Option { + None, Some(A) }` must key equal to the prelude's. Reordering the + constructors DOES change the layout — the tag is the position — so it + must not, which is where this test is stronger than the + `_has_standard_json` family's set comparison it sits beside. + """ + from vera.parser import parse_to_ast + from vera.prelude import data_decl_shape, prelude_data_decls + + def shape(src: str) -> object: + decl = parse_to_ast(src).declarations[0].decl + return data_decl_shape(decl) + + prelude = prelude_data_decls() + assert shape("private data Option { None, Some(A) }") == ( + data_decl_shape(prelude["Option"])) + assert shape("private data Ordering { Less, Equal, Greater }") == ( + data_decl_shape(prelude["Ordering"])) + assert shape("private data Ordering { Equal, Less, Greater }") != ( + data_decl_shape(prelude["Ordering"])) + assert shape("private data Option { None, Some(Int) }") != ( + data_decl_shape(prelude["Option"])) + + +def test_prelude_adt_names_are_exactly_what_the_prelude_injects( + tmp_path: Path, +) -> None: + """`prelude_adt_names()` and `inject_prelude` cannot drift apart. + + The floor is only as complete as this set, and the set is only right + if it names every ADT the injector can lay down. So it is compared + against the injector itself, run over a program that demands every + conditional block — not against a list repeated in the test. + """ + from vera.prelude import prelude_adt_names + + demands_everything = """ +public fn everything(@Json, @HtmlNode, @Request -> @Response) + requires(true) + ensures(true) + effects(pure) +{ + Response(200, map_new(), "") +} +""" + program = transform(parse_file(str(_write( + tmp_path, "all.vera", demands_everything)))) + inject_prelude(program) + injected = frozenset( + tld.decl.name for tld in program.declarations + if type(tld.decl).__name__ == "DataDecl" + ) + assert injected == prelude_adt_names(), ( + sorted(injected ^ prelude_adt_names())) + # A tripwire on the set itself: the four beyond the Pass-0.5 built-in + # snapshot are the ones the floor exists for. + assert {"Json", "HtmlNode", "Request", "Response"} <= prelude_adt_names() + + +def _write(tmp_path: Path, name: str, text: str) -> Path: + tmp_path.mkdir(parents=True, exist_ok=True) + path = tmp_path / name + path.write_text(text, encoding="utf-8") + return path diff --git a/tests/test_prelude_decl_stamp_1287.py b/tests/test_prelude_decl_stamp_1287.py new file mode 100644 index 00000000..1a3f24a6 --- /dev/null +++ b/tests/test_prelude_decl_stamp_1287.py @@ -0,0 +1,215 @@ +"""#1287: the prelude's declaration block is a fact about the prelude. + +``_stamp_decl_order`` guards on ``name in self._decl_order`` before it +stamps anything. ``_decl_order`` is the ACTIVE namespace — the main +file's, stamped from 0 in Pass 1 — so a main-file ``type Option = Int`` +made the guard fire on the PRELUDE stamp in Pass 1.2, and the prelude's +own ``Option`` never entered ``_prelude_decl_order``. + +That map is not a namespace. ``_module_alias_scope`` builds every +module's index space as ``{**_prelude_decl_order, **module_own}``, so it +is the base layer UNDER every other namespace, and letting a main-file +declaration decide its contents is precisely the cross-namespace leak +``_decl_order`` and ``_module_decl_order`` were split apart to prevent +(PR #1224 review, quoted at ``vera/codegen/core.py``'s ``_decl_order``). + +Two consequences, both measured here at the branch point: + +1. ``Option`` is absent from the prelude block, and every prelude + declaration AFTER it is off by one — the counter never advanced — + so the block is a function of the main file's declarations, not of + ``inject_prelude``'s output. +2. Inside a module's namespace the prelude ``Option`` then resolves at + ``_BUILTIN_DECL_INDEX`` instead of its prelude position, and that + wrong index is what reaches ``AliasEnv.data_types``, the value a + consumer reads. + +No rendering moves for these names — ``data_types`` changes an answer +only for ``Decimal`` and the single ``REMOVED_ALIASES`` entry ``Float`` +(``naming._resolve_named``), and no prelude ADT is either — so, exactly +as ``test_adt_membership_scope_1253`` does for the same reason, the +assertions are on the index the consumer receives rather than on a +rendering that cannot distinguish it. + +The control in every case is the SAME program without the shadowing +alias: the claim is an invariance, so the fixture states it as one. +""" + +from __future__ import annotations + +from pathlib import Path + +from vera.codegen.core import _BUILTIN_DECL_INDEX, CodeGenerator +from vera.parser import parse_file +from vera.resolver import ModuleResolver +from vera.transform import transform + +_MLIB = """ +public fn tag(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + 1 +} +""" + +# `Option` is a prelude ADT, and a main-file `type` of the same name is +# accepted (spec §8.4.1: the prelude's data types are ordinary public +# declarations a program names and shadows; the reserved namespace is the +# `Vera` prefix alone, E154). `inject_prelude` skips a prelude DataDecl +# only when the user declared a `data` of that name, so the prelude's +# `data Option` is still injected here — which is what makes the two +# programs below inject exactly the same prelude. +_SHADOWING = """ +import mlib(tag); + +type Option = Int; + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + tag(()) +} +""" + +_CONTROL = """ +import mlib(tag); + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + tag(()) +} +""" + + +def _compiled(tmp_path: Path, main_src: str) -> CodeGenerator: + """Compile *main_src* against ``mlib`` and hand back the generator.""" + tmp_path.mkdir(parents=True, exist_ok=True) + (tmp_path / "mlib.vera").write_text(_MLIB, encoding="utf-8") + main_path = tmp_path / "main.vera" + main_path.write_text(main_src, encoding="utf-8") + program = transform(parse_file(str(main_path))) + mods = ModuleResolver(tmp_path).resolve_imports(program, main_path) + gen = CodeGenerator( + source=main_path.read_text(encoding="utf-8"), file=str(main_path), + ) + gen._resolved_modules = mods + gen.compile_program(program) + return gen + + +def test_prelude_block_does_not_depend_on_the_main_file( + tmp_path: Path, +) -> None: + """The same prelude injection produces the same prelude index block. + + `inject_prelude` lays down an identical declaration list for both + programs (the shadowing one declares an ALIAS, and only a `data` of + that name suppresses a prelude DataDecl), so the block they stamp + must be identical too. At the branch point the shadowing program's + block is missing `Option` AND has every later prelude declaration + shifted one place earlier, because the skipped stamp never advanced + `_prelude_decl_order_next`. + """ + shadowing = _compiled(tmp_path / "s", _SHADOWING) + control = _compiled(tmp_path / "c", _CONTROL) + + # The control is the oracle for what the prelude actually injected — + # asserted, not assumed, so a prelude that stopped injecting `Option` + # would fail here rather than make the comparison vacuous. + assert "Option" in control._prelude_decl_order, sorted( + control._prelude_decl_order) + assert control._prelude_decl_order["Option"] == min( + control._prelude_decl_order.values()) + + missing = set(control._prelude_decl_order) - set( + shadowing._prelude_decl_order) + assert not missing, ( + f"a main-file `type` removed {sorted(missing)} from the prelude's " + f"own index block" + ) + assert shadowing._prelude_decl_order == control._prelude_decl_order, { + name: (shadowing._prelude_decl_order.get(name), idx) + for name, idx in control._prelude_decl_order.items() + if shadowing._prelude_decl_order.get(name) != idx + } + + +def test_module_namespace_keeps_the_prelude_index(tmp_path: Path) -> None: + """Inside a module, the shadowed prelude ADT sits where the prelude put it. + + The index reaches consumers through `AliasEnv.data_types`, which is + where it is asserted. `_BUILTIN_DECL_INDEX` is the wrong answer + twice over: it is not the prelude's position, and it is *below* + `_PRELUDE_DECL_BASE`, so it orders the prelude's `Option` ahead of + every other prelude declaration rather than among them. + """ + shadowing = _compiled(tmp_path / "s", _SHADOWING) + control = _compiled(tmp_path / "c", _CONTROL) + + with control._module_alias_scope(("mlib",)): + expected = control._alias_env.data_types["Option"] + with shadowing._module_alias_scope(("mlib",)): + actual = shadowing._alias_env.data_types["Option"] + + assert expected != _BUILTIN_DECL_INDEX, ( + "the control already reads the builtin floor — the fixture no " + "longer distinguishes anything" + ) + assert actual != _BUILTIN_DECL_INDEX, ( + f"prelude `Option` fell back to the builtin floor " + f"({_BUILTIN_DECL_INDEX}) inside the module namespace" + ) + assert actual == expected, (f"{actual} != {expected}") + + +def test_the_prelude_stamp_is_idempotent_by_name() -> None: + """Stamping one prelude name twice records it once, at one index. + + `_stamp_decl_order` documents itself as idempotent by name, and the + unconditional prelude write has to keep that promise on its own now + that it no longer borrows `_decl_order`'s guard — a second write + would move the name AND advance the counter, shifting every prelude + declaration after it. Exercised directly, because the injection + loop calls the method once per declaration and so cannot reach the + second call; the guard is a property of the method, and this is + where it is stated. + """ + gen = CodeGenerator(source="", file="") + gen._stamp_decl_order("Zephyr", prelude=True) + first = gen._prelude_decl_order["Zephyr"] + after_one = gen._prelude_decl_order_next + gen._stamp_decl_order("Zephyr", prelude=True) + assert gen._prelude_decl_order["Zephyr"] == first + assert gen._prelude_decl_order_next == after_one + assert gen._decl_order["Zephyr"] == first + # And a NEW name still takes the next slot, so idempotence is by name + # rather than a counter that stopped moving. + gen._stamp_decl_order("Nimbus", prelude=True) + assert gen._prelude_decl_order["Nimbus"] == first + 1 + + +def test_the_main_file_declaration_still_wins_its_own_namespace( + tmp_path: Path, +) -> None: + """The shadow keeps the main namespace; only the prelude BLOCK changes. + + Green before and after — the control that separates "record the + prelude's own order" from "let the prelude overwrite the main file's + stamp". A fix that stamped `_decl_order` unconditionally would make + the main file's `type Option` order AFTER the prelude's ADT of the + same name and fail here. + """ + gen = _compiled(tmp_path, _SHADOWING) + # `type Option = Int` is the main file's first (and only) type-space + # declaration, so it holds index 0 in its own namespace — a value no + # fallback and no prelude index can coincide with (the prelude block + # is negative, the builtin floor more negative still). + assert gen._decl_order["Option"] == 0, gen._decl_order + assert gen._alias_env.data_types["Option"] == 0 diff --git a/vera/README.md b/vera/README.md index 2d79bf4c..fe7e1da9 100644 --- a/vera/README.md +++ b/vera/README.md @@ -77,7 +77,7 @@ execute(compile_result, ...) # → run WASM via wasmtime | `transform.py` | 1,572 | Transform | Lark tree → AST transformer | `transform()` | | `ast.py` | 895 | Transform | Frozen dataclass AST nodes, source formatting | `Program`, `Node`, `Expr`, `format_expr` | | `types.py` | 814 | Type check | Semantic type representation | `Type`, `is_subtype()` | -| `prelude.py` | 1,004 | Type check | Standard prelude — built-in ADT and combinator injection | `inject_prelude()`, `overridable_builtin_names()` | +| `prelude.py` | 1,008 | Type check | Standard prelude — built-in ADT and combinator injection | `inject_prelude()`, `prelude_adt_names()`, `overridable_builtin_names()` | | `naming.py` | 789 | Type check | The ONE slot / slot-reference-key / State-Exn-family renderer (#1208, #1209) — the checker's rendering, as a total pure function over an `AliasEnv`, consumed by the checker, the monomorphizer, the verifier, the SMT layer, codegen, the tester, the LSP, and `vera check --explain-slots`. Also the ONE refinement-binder derivation, from the type expression for codegen's runtime guard (`refinement_binder_parts`) and from the predicate's own reference for the verifier and SMT layers (`predicate_binder_key`, #1226), both rendering through `slot_name`; and each consumer is handed the env of the module that DECLARED what it is rendering | `slot_name()`, `slot_ref_key()`, `family_name()`, `resolve_type_expr()`, `AliasEnv` | | `slots.py` | 396 | Type check | Presentation over `naming.py`: slot resolution tables and their text/JSON rendering, plus the two scope walks the tables need (`forall` narrowing, `where`-helper nesting). The walks here that are NOT naming say so in their docstrings — the alias-opaque syntactic spelling for WASM representation questions, the last-resort name for a State/Exn cell family that resolves to none, and the bare-call ownership predicate the checker, codegen, and mono discovery all resolve a `get`/`put` call site through | `slot_table()`, `format_slot_table()`, `fn_slot_scope()`, `fn_scopes()`, `type_expr_slot_name()`, `family_fallback_name()`, `bare_call_denotes_user_fn()` | | `environment.py` | 2,424 | Type check | Type environment, scope stacks, ability registry, all built-in registrations | `TypeEnv`, `AbilityInfo` | @@ -748,7 +748,7 @@ Every diagnostic has a unique code grouped by compiler phase: | E5xx | Verification | `verifier.py` | | E6xx | Codegen | `codegen/` | -The `ERROR_CODES` dict in `errors.py` maps every code to a short description (156 entries — 154 `E` codes and the two `W` warning codes). Codes are stable across versions — they can be used for programmatic filtering, suppression, and documentation lookups. Formatted output shows the code in brackets: `[E130] Error at line 5, column 3:`. +The `ERROR_CODES` dict in `errors.py` maps every code to a short description (157 entries — 155 `E` codes and the two `W` warning codes). Codes are stable across versions — they can be used for programmatic filtering, suppression, and documentation lookups. Formatted output shows the code in brackets: `[E130] Error at line 5, column 3:`. ## Test Suite diff --git a/vera/_since.py b/vera/_since.py index 681b08e3..0f44ac5e 100644 --- a/vera/_since.py +++ b/vera/_since.py @@ -366,6 +366,7 @@ "E618": "0.0.188", "E619": "0.1.0", "E620": "0.1.9", + "E621": "0.1.12", "E699": "0.0.145", "E700": "0.0.47", "E701": "0.0.47", diff --git a/vera/codegen/core.py b/vera/codegen/core.py index 526ec1ac..394665a0 100644 --- a/vera/codegen/core.py +++ b/vera/codegen/core.py @@ -33,7 +33,12 @@ qualify_nested_generic_decls, ) from vera.naming import EMPTY_ALIAS_ENV, AliasEnv -from vera.prelude import PRELUDE_FILE, mentioned_fn_names +from vera.prelude import ( + PRELUDE_FILE, + data_decl_shape, + mentioned_fn_names, + prelude_adt_names, +) from vera.slots import family_fallback_name from vera.wasm import StringPool from vera.wasm.helpers import CellNames @@ -370,14 +375,24 @@ def __init__( # The builtin ADTs, members of every namespace (they are global # infrastructure, owned by no module — the same set `_register_modules` # exempts from the E609/E610 collision rails). A FLOOR, not the whole - # infrastructure set: it is snapshotted in Pass 0.5 and the prelude's - # own ADTs register in Pass 1.2, so `_adt_members_in_scope` derives the - # rest by subtracting what the namespaces declare. + # infrastructure set: it is snapshotted in Pass 0.5, and the PRELUDE's + # own ADTs register in Pass 1.2, so `_adt_members_in_scope` completes + # it with `prelude.prelude_adt_names()` (#1277) and derives whatever + # remains by subtracting what the namespaces declare. self._builtin_adt_names: frozenset[str] = frozenset() # Every ADT name SOME namespace declares — the main program's and each # module's own declarations. Whatever `_adt_layouts` holds beyond this # is global infrastructure and belongs to every namespace. self._namespace_declared_adts: frozenset[str] = frozenset() + # #1277: ADT name → EVERY module that declares it, in resolution + # order, read from the declarations rather than from the registered + # layouts, so the Pass-1.2 contention rail sees a module's `data + # Option` as well as its `data Json` — and sees the second declarer + # as well as the first. Distinct from `_adt_layout_owners`, which + # records which namespace's LAYOUT won the flat slot and is read for + # declaration ordering: that one is first-wins because a slot has + # one winner, while contention is a property of each declaration. + self._module_adt_declarers: dict[str, tuple[tuple[str, ...], ...]] = {} # The namespace `_module_alias_scope` currently has installed, so # `_sync_alias_env` knows whose membership to apply. self._active_module_path: tuple[str, ...] | None = None @@ -1374,20 +1389,40 @@ def _adt_members_in_scope(self) -> frozenset[str] | None: asymmetry between the two sides' notions of "builtin"). Subtracting what the namespaces declare cannot go stale with registration order. - The builtin snapshot is unioned in as well, but it protects only what - it contains — the Pass-0.5 built-ins (``Option``, ``Result``, - ``Tuple``, …). It does NOT cover the four demand-injected prelude - ADTs above, which is the same Pass-0.5-vs-1.2 asymmetry one layer - down: a module declaring ``data Json`` puts ``Json`` into the - declared set, subtracting it from infrastructure and hiding the - PRELUDE ``Json`` from every namespace but that module's (measured; - the entry program's members lose it). No E609/E610 collision rail - fires on such a declaration either, since those rails are keyed on - the same Pass-0.5 snapshot. Inert today for the reason the rest of - this membership rule is inert — ``data_types`` changes an answer only - for ``Decimal`` and ``REMOVED_ALIASES`` — but it is a real constraint - on any future consumer, and closing it means teaching Pass 0.5 which - prelude ADTs the program will demand. + Subtraction alone is sound only while "declared by a namespace" and + "global infrastructure" are DISJOINT, and §8.4.1 makes them overlap + on purpose: the prelude's data types are ordinary public + declarations a program may name and shadow. So the floor unioned + in has to state the prelude's names positively rather than let them + be recovered by elimination (#1277). Two sets do that, and they + answer different questions: + + - ``_builtin_adt_names`` — the Pass-0.5 snapshot of + ``_register_builtin_adts`` (``Option``, ``Result``, ``Tuple``, …), + which is also the set the E609/E610 collision rails exempt. + - :func:`~vera.prelude.prelude_adt_names` — every ADT the PRELUDE + can provide, which is the half the snapshot cannot hold: ``Json``, + ``HtmlNode``, ``Request`` and ``Response`` register in Pass 1.2, + after this membership is computed. Without it, one file's ``data + Json`` removed ``Json`` from every OTHER namespace's members — + including the entry program's, which never declared it and + legitimately sees the prelude's — while the checker's ``TypeEnv`` + carries the name in every namespace unconditionally. + + Naming a prelude ADT the program never demanded is inert, so this + floor does not condition on demand where the checker does not — + but the reason is NOT that an unregistered name is filtered out + downstream. That is true of a name with no layout at all, and + false in exactly the case this floor is about: when a module has + declared the name, a layout IS registered, and it is the module's. + What makes it inert is narrower and is a property of today's only + consumer — ``naming._resolve_named`` reads ``data_types`` for the + index alone, and the index changes a rendering only for + ``Decimal`` and the single ``REMOVED_ALIASES`` entry ``Float``, + neither of which the prelude declares. A future consumer that + reads the set for anything else would see the module's layout + under the prelude's name — which is why the Pass-1.2 rail refuses + that program (E621) rather than leaving the two to disagree. """ if not self._adt_namespace_members: return None @@ -1397,7 +1432,10 @@ def _adt_members_in_scope(self) -> frozenset[str] | None: infrastructure = ( frozenset(self._adt_layouts) - self._namespace_declared_adts ) - return members | infrastructure | self._builtin_adt_names + return ( + members | infrastructure + | self._builtin_adt_names | prelude_adt_names() + ) def _adt_decl_index(self, name: str, order: dict[str, int]) -> int: """Where *name* sits in the declaration-index space *order* keys (#1227). @@ -1438,16 +1476,151 @@ def _stamp_decl_order(self, name: str, *, prelude: bool = False) -> None: declarations precede the main file's whatever order codegen happens to walk them in — and, being recorded in ``_prelude_decl_order`` too, precede every module's as well. + + That second record is written UNCONDITIONALLY (#1287). + ``_prelude_decl_order`` is not a namespace: ``_module_alias_scope`` + builds every module's space as ``{**_prelude_decl_order, + **module_own}``, so it is the base layer under all of them, and its + contents are a fact about what ``inject_prelude`` laid down. Keying + the write on ``_decl_order`` — the ACTIVE, main-file namespace — + let a main-file declaration decide it: ``type Option = Int`` is + accepted (§8.4.1 — the prelude's data types are ordinary public + declarations a program may shadow; only the ``Vera`` prefix is + reserved, E154) and does NOT suppress the prelude's ``data + Option``, so the guard fired on the prelude stamp and left + ``Option`` out of the block entirely — resolving at + ``_BUILTIN_DECL_INDEX`` inside every module namespace, and shifting + every later prelude declaration one place earlier because the + counter never advanced. That is exactly the cross-namespace leak + ``_decl_order`` and ``_module_decl_order`` were split apart to + prevent (PR #1224 review). + + The ACTIVE space still takes the main file's stamp: `setdefault` + leaves a name the main file already declared where the main file put + it, so the shadow keeps winning its own namespace. """ + if prelude: + if name not in self._prelude_decl_order: + self._prelude_decl_order[name] = self._prelude_decl_order_next + self._prelude_decl_order_next += 1 + self._decl_order.setdefault(name, self._prelude_decl_order[name]) + return if name in self._decl_order: return - if prelude: - self._decl_order[name] = self._prelude_decl_order_next - self._prelude_decl_order[name] = self._prelude_decl_order_next - self._prelude_decl_order_next += 1 - else: - self._decl_order[name] = self._decl_order_next - self._decl_order_next += 1 + self._decl_order[name] = self._decl_order_next + self._decl_order_next += 1 + + def _contends_with_prelude( + self, prelude_decl: ast.DataDecl, owner: tuple[str, ...], + ) -> bool: + """Can *owner*'s declaration of this name share the prelude's layout? + + The flat map holds ONE layout per name, so two declarations of a + prelude name are a contention exactly when they describe different + layouts (:func:`~vera.prelude.data_decl_shape`). A module that + restates the prelude's own type — same constructors, same order, + same field types, type parameters compared positionally — is not a + contention: the single registered layout is correct for both, which + is why such programs compile and run today and must keep doing so. + `examples/vera/collections.vera` is that shape in this repository: + it declares `public data Option { None, Some(T) }`, which + `examples/modules.vera` imports, so a rail keyed on the name alone + refuses a shipped example. + + A module whose declaration this cannot find (the name is declared, + but the resolved module's AST no longer holds the node) is treated + as contending — the safe direction, since the alternative is + sharing a layout that may not fit. + """ + module_decl = self._find_module_data_decl(owner, prelude_decl.name) + if module_decl is None: # pragma: no cover — defensive + return True + return ( + data_decl_shape(module_decl) != data_decl_shape(prelude_decl) + ) + + def _emit_prelude_adt_contention_error( + self, name: str, owner: tuple[str, ...], + ) -> None: + """Report a module ADT that took a demanded prelude ADT's name (#1277). + + Located at the MODULE's declaration, in the module's own file — + the declaration the user can act on. Before this rail the only + report was the wreckage: an E602 for an unknown constructor inside + the prelude's own combinator, an E620 cascade behind it, every one + of them at ```` coordinates and none of them naming + ``data {name}`` or the module it is in. + + The sibling of E609/E610 (§11.16): one flat WASM namespace, one + layout per name. Those rails compare two IMPORTED modules and + exempt the Pass-0.5 built-in snapshot, which is taken before the + prelude's own ADTs register — this is the same collision against + the half of global infrastructure that snapshot cannot hold. + + Both branches of the fix are measured, not supposed. RENAMING is + always available. Matching the prelude's shape works because the + one registered layout then serves both declarations — the same + condition :meth:`_contends_with_prelude` tests, so the instruction + and the rail cannot disagree. Telling the user to redeclare the + type in the ENTRY file would not be true: a differently-shaped + entry declaration suppresses the prelude's injection and silently + drops the functions that use the module's version instead. + """ + mod = ".".join(owner) + decl = self._find_module_data_decl(owner, name) + loc = SourceLocation(file=self.file) + source_line = "" + resolved = next( + (m for m in self._resolved_modules if m.path == owner), None) + if resolved is not None: + loc = SourceLocation(file=str(resolved.file_path)) + if decl is not None and decl.span: + loc.line = decl.span.line + loc.column = decl.span.column + lines = resolved.source.splitlines() + if 1 <= loc.line <= len(lines): + source_line = lines[loc.line - 1] + self.diagnostics.append(Diagnostic( + description=( + f"Imported module '{mod}' declares a data type '{name}' " + f"whose shape differs from the prelude's '{name}', and " + f"both are compiled into this program." + ), + location=loc, + source_line=source_line, + rationale=( + "The flat compilation strategy (C7e) gives the whole " + "program one ADT namespace, and the prelude's data types " + "are compiled into it alongside every imported module's. " + "One name carries one constructor layout there, so two " + f"differently-shaped declarations of '{name}' cannot both " + "be registered: the module's takes the layout, the " + "prelude's is dropped, and every function that uses the " + "prelude type is dropped behind it." + ), + fix=( + f"Rename '{name}' in module '{mod}' and update that " + f"module's uses of it. If the module means the prelude's " + f"type, give its declaration the prelude's shape instead " + f"— the same constructors, in the same order, with the " + f"same field types — and the one layout serves both." + ), + spec_ref='Chapter 11, Section 11.16 "Cross-Module Compilation"', + severity="error", + error_code="E621", + )) + + def _find_module_data_decl( + self, mod_path: tuple[str, ...], name: str, + ) -> ast.DataDecl | None: + """*mod_path*'s ``data {name}`` declaration, for its span.""" + for mod in self._resolved_modules: + if mod.path != mod_path: + continue + for tld in mod.program.declarations: + if isinstance(tld.decl, ast.DataDecl) and tld.decl.name == name: + return tld.decl + return None def compile_program(self, program: ast.Program) -> CompileResult: """Compile a complete Vera program to WebAssembly.""" @@ -1586,6 +1759,11 @@ def compile_program(self, program: ast.Program) -> CompileResult: # spans index into it, and `_diag_location` quotes it (under # the `` origin) for prelude-origin diagnostics. self._prelude_source = inject_prelude(program) + # #1277: prelude ADTs whose name an IMPORTED module has already + # taken in `_adt_layouts`. One flat layout map, one slot per name, + # so the two declarations contend and the module's — registered back + # in Pass 0.5 — wins by arriving first. + contended: list[tuple[str, tuple[str, ...]]] = [] for tld in program.declarations: if id(tld) in pre_inject_ids: continue @@ -1596,12 +1774,50 @@ def compile_program(self, program: ast.Program) -> CompileResult: # orders aliases and ADTs against each other. if isinstance(tld.decl, (ast.TypeAliasDecl, ast.DataDecl)): self._stamp_decl_order(tld.decl.name, prelude=True) + if isinstance(tld.decl, ast.DataDecl): + # Asked by OBSERVING what `inject_prelude` laid down rather + # than by re-deriving its demand predicates in Pass 0.5, + # where the E609/E610 rails live: a second copy of "does + # this program want Json?" is a second thing to keep in + # step, and the identity filter above already says exactly + # what was injected. A main-file shadow suppresses the + # injection outright, so it never reaches here — which is + # what keeps the §8.4.1 entry-file shadow legal. + # + # Read off the DECLARATIONS (`_module_adt_declarers`), not + # the registered layouts: the harvest skips a built-in name, + # so `_adt_layout_owners` sees `data Json` and never `data + # Option`, and keying the rail on it covered four of the + # prelude's eight names while §8.4.1 and §11.16 claim all + # eight. EVERY declarer is asked, because each declaration + # contends on its own — a first-wins lookup let a module + # that restates the prelude's type answer for a sibling + # that does not, which made the rail order-dependent. + for owner in self._module_adt_declarers.get( + tld.decl.name, (), + ): + if self._contends_with_prelude(tld.decl, owner): + contended.append((tld.decl.name, owner)) if isinstance(tld.decl, ast.TypeAliasDecl): self._prelude_type_aliases[tld.decl.name] = tld.decl.type_expr if tld.decl.type_params: self._prelude_type_alias_params[tld.decl.name] = ( tld.decl.type_params ) + # Reported at the declaration that caused it, and refused by the + # Pass-1.9 severity gate below — the route E608 / E609 / E610 take, + # so there is ONE refusal mechanism rather than a second early + # return beside it (measured: an early return here changes neither + # the diagnostics nor the empty exports, including on a shape whose + # monomorphization runs over the contended type in between). + # Without the report, registration proceeds against the module's + # layout: the prelude's own combinators fail on its constructors + # (an E602 inside ``), every user function touching the + # type is dropped behind an E620 cascade, and all of it is reported + # as WARNINGS — a zero-exit compile of a module with the functions + # silently missing. + for name, owner in contended: + self._emit_prelude_adt_contention_error(name, owner) for tld in program.declarations: decl = tld.decl if isinstance(decl, ast.FnDecl) and decl.name not in existing_fns: diff --git a/vera/codegen/modules.py b/vera/codegen/modules.py index ced8a9f3..a0575e2d 100644 --- a/vera/codegen/modules.py +++ b/vera/codegen/modules.py @@ -831,6 +831,32 @@ def visible( # register after this pass runs and so cannot be snapshotted here. self._namespace_declared_adts = frozenset(main_own).union( *declared_adts.values()) if declared_adts else frozenset(main_own) + # #1277: which MODULES declare each ADT name, read from the + # declarations rather than from `_adt_layouts`, so the Pass-1.2 + # contention rail can see a module's `data Option` at all. The + # layout harvest above skips a built-in name outright (the temp + # generator registers `Option`, `Result`, … for EVERY module, + # declared or not, so `_adt_layouts` cannot tell the two apart) and + # `_adt_layout_owners` therefore records only the non-built-in half + # — which left the rail covering four of the prelude's eight names. + # + # EVERY declarer, in resolution order, not the first: contention is + # a property of each declaration, and a first-wins map made the rail + # order-dependent. A library that restates the prelude's `Ordering` + # answered for a sibling that declares a different one, so importing + # the restating module first hid the other's contention entirely + # (check-green, exit 0, the caller silently dropped) while the + # reverse import order caught it. Ownership of the LAYOUT stays + # first-wins in `_adt_layout_owners`, which answers the declaration- + # index question — the same separation of two questions that keeps + # `_namespace_declared_adts` out of this one. + declarers: dict[str, list[tuple[str, ...]]] = {} + for mod_path, names in declared_adts.items(): + for adt_name in sorted(names): + declarers.setdefault(adt_name, []).append(mod_path) + self._module_adt_declarers = { + name: tuple(paths) for name, paths in declarers.items() + } return members def _generics_cannot_collide( diff --git a/vera/errors.py b/vera/errors.py index 56b740bf..04165cf9 100644 --- a/vera/errors.py +++ b/vera/errors.py @@ -804,6 +804,7 @@ def diagnose_lark_error( "E618": "Nested refinement base unsupported", "E619": "Cannot infer type argument for ability-constrained parameter", "E620": "Function dropped: skipped callee or no function table", + "E621": "Name collision: module ADT contends with a prelude data type", "E699": "Internal compiler error", # E7xx — Testing "E700": "Contract violation during testing", diff --git a/vera/prelude.py b/vera/prelude.py index 39bb4569..c1a23809 100644 --- a/vera/prelude.py +++ b/vera/prelude.py @@ -16,6 +16,7 @@ from __future__ import annotations +import functools import re from vera import ast @@ -60,6 +61,12 @@ data HtmlNode { HtmlElement(String, Map, Array), HtmlText(String), HtmlComment(String) } """ +# Every source block above that declares an ADT. Read only by +# :func:`prelude_adt_names`; a new prelude ADT block belongs here. +_PRELUDE_DATA_SOURCES = ( + _PRELUDE_DATA, _HTTP_SERVER_DATA, _JSON_DATA, _HTML_DATA, +) + _HTML_COMBINATORS = """\ private fn html_attr(@HtmlNode, @String -> @Option) requires(true) @@ -772,6 +779,107 @@ def _parse_source(source: str) -> ast.Program: # Public API # ===================================================================== +@functools.lru_cache(maxsize=1) +def prelude_data_decls() -> dict[str, ast.DataDecl]: + """The prelude's own ``data`` declarations, by name (#1277). + + Derived by PARSING the same source blocks :func:`inject_prelude` + concatenates, with the same parser, so a new prelude ADT joins by + being written — no second list to keep in step, and no regex + approximating the grammar. Cached: the blocks are constants. + + Two consumers, one derivation: :func:`prelude_adt_names` (codegen's + ADT-membership floor) and codegen's Pass-1.2 contention rail, which + compares a module's declaration of one of these names against the + prelude's own with :func:`data_decl_shape`. + """ + parsed = _parse_source("\n".join(_PRELUDE_DATA_SOURCES)) + return { + tld.decl.name: tld.decl + for tld in parsed.declarations + if isinstance(tld.decl, ast.DataDecl) + } + + +def prelude_adt_names() -> frozenset[str]: + """Every ADT name the prelude can provide (#1277). + + The checker registers all of them in every ``TypeEnv`` + unconditionally (:mod:`vera.environment`), so they are data types in + every namespace whatever a program declares — and codegen's + per-namespace ADT membership (#1253) has to say the same, or the two + sides disagree about what a NAME MEANS. Its Pass-0.5 built-in + snapshot covers only ``_register_builtin_adts``, which is taken + before Pass 1.2 injects ``Json``, ``HtmlNode``, ``Request`` and + ``Response``; this set is the floor that completes it. + + Unconditional, deliberately: whether a given program DEMANDS a block + is `inject_prelude`'s question, and membership must not condition on + it where the checker does not. Whether the name is CONTENDED is a + third question, asked by codegen's Pass-1.2 rail on the declarations + themselves — see ``_adt_members_in_scope`` for why naming an + undemanded ADT here is inert (a property of today's consumer, not of + the set) and why the contended case is refused rather than resolved. + """ + return frozenset(prelude_data_decls()) + + +def data_decl_shape(decl: ast.DataDecl) -> tuple[object, ...]: + """*decl*'s LAYOUT identity — what two declarations must share (#1277). + + Two ``data`` declarations of one name can occupy codegen's single + flat layout slot only if they describe the same layout, which is + the type-parameter arity plus each constructor's name, tag position + and field types. Type parameters are normalised POSITIONALLY, so + ``data Option { None, Some(A) }`` is the prelude's ``Option`` and + not a contention: a renamed parameter changes no layout. + + Constructor ORDER is significant, because the tag is the position — + which is why this is a stronger test than the set comparison + :func:`_has_standard_json` and its siblings make when deciding + whether the prelude's combinators can be injected over a user's + declaration. Those answer "will the match arms type-check?"; this + answers "can one registered layout serve both?". + + A field type this does not model reduces to its node class, which + can only ever make two declarations compare DIFFERENT — the prelude's + own fields are all named types, so the coarse arm is never on both + sides of a comparison. Different is the safe direction: it refuses + a compile rather than sharing a layout that may not fit. + """ + slots = { + name: f"#{i}" for i, name in enumerate(decl.type_params or ()) + } + return ( + len(decl.type_params or ()), + tuple( + (ctor.name, tuple( + _type_shape_key(field, slots) + for field in (ctor.fields or ()) + )) + for ctor in decl.constructors + ), + ) + + +def _type_shape_key(te: object, slots: dict[str, str]) -> str: + """A deterministic key for a field type — see :func:`data_decl_shape`.""" + if isinstance(te, ast.NamedType): + base = slots.get(te.name, te.name) + args = te.type_args or () + if not args: + return base + inner = ",".join(_type_shape_key(a, slots) for a in args) + return f"{base}<{inner}>" + if isinstance(te, ast.FnType): + params = ",".join(_type_shape_key(p, slots) for p in te.params) + return f"fn({params})->{_type_shape_key(te.return_type, slots)}" + if isinstance(te, ast.RefinementType): + base = _type_shape_key(te.base_type, slots) + return f"{{{base}|{ast.format_expr(te.predicate)}}}" + return f"?{type(te).__name__}" + + def inject_prelude(program: ast.Program) -> str: """Inject prelude ADTs, combinators, and array operations. From b22831349be4e6ec15f517ca24c0137b979cb717 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 11:14:41 +0100 Subject: [PATCH 27/69] Record two name-collision bugs the burndown surfaced but did not close Both were found while closing #1287 and #1277, both are pre-existing at 8e08ec90, and neither is fixed here. the ADT's pointer width, so `type Option = Int;` with a `@Option` parameter passes check and verify and then fails WebAssembly validation. The row's teeth are the silent arm: at matching widths (`type Ordering = Bool`) validation raises nothing and the body reads a pointer as its aliased value. silently drop the caller when their shapes differ: check-green, exit 0, warnings only, and the entry's `main` missing from the exports. It is the same one-layout-per-name defect as #1277 on a pair its E621 rail structurally cannot see, since an entry-file declaration suppresses the prelude's injection and the rail compares against the prelude's. The fix direction points at the shape predicate and the compile-stage negative machinery this branch installed. Co-Authored-By: Claude --- KNOWN_ISSUES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 7bd2d25a..b4d84323 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -8,7 +8,9 @@ Defects in shipped compiler, runtime, or tooling behaviour — this table matche | Bug | Issue | |-----|-------| +| An entry-file `data` declaration and a module's of the same name silently drop the caller when their shapes differ. `private data Json { JMine(Int) }` in the entry beside `private data Json { JBlob(Int) }` in an imported module is `vera check`-green and then compiles with **exit 0**, `ok: true`, and nothing but `[E602]`/`[E620]` **warnings** — and the entry's `main`, which calls the module's function, is absent from the exports (measured: `exports == ['consume']`, the one function that touches only the entry's own declaration). The artifact loads and simply lacks its entry point. The flat namespace holds one layout per name and the entry's declaration takes it — Pass 1 registers the main file's `data` over the Pass-0.5 module harvest, which only `setdefault`s — so the module's own constructors become `unknown constructor` inside its own bodies. Same one-layout-per-name defect as [#1277](https://github.com/aallan/vera/issues/1277) and **not** closed by its E621 rail, for a structural reason rather than an oversight: that rail compares a PRELUDE declaration against a module's, and an entry-file declaration *suppresses* the prelude's injection outright, so no prelude declaration ever reaches Pass 1.2 and the contending pair here — entry versus module — is one the rail cannot be asked about. Behaviourally identical at `8e08ec90` and after #1277's fix, so pre-existing rather than introduced. Fix direction: put the entry-versus-module pair through the same contention check, reusing the machinery #1277 installed — `vera/prelude.py`'s `data_decl_shape` already decides whether two declarations of one name can share a layout (it is what keeps a module's restatement of a prelude type legal), and the conformance manifest's `expected_error_stage: "compile"` already expresses a check-green/compile-refused negative — leaving the ruling: a new code, or E609 widened from module-versus-module to take the entry file as a namespace, located at both declarations. | [#1312](https://github.com/aallan/vera/issues/1312) | | A module-declared generic instantiated at a type that comes from an EFFECT-OPERATION result never registers its clone. Codegen reports a compilation note (`call target 'mod$$$' not registered`) and silently drops the calling function, so the emitted module has no `main` — from a program `vera check` and `vera verify` both pass. Two conditions, bisected and both necessary: the generic is declared in a **module** (so its clone is the `mod$`-mangled form) and its type argument is inferred from an effect operation's result. Neither nesting under a `where` helper nor `private` visibility is needed — `public` fails identically. Controls that pass: a literal type argument, a type argument from a plain module-function result, and the identical shape with the generic declared in the entry file. This is the `mod$`-mangled REGISTRATION path for module generics (the #1274/#1281 machinery), distinct from #1299's scope-table divergence — measured identical on `release/v0.1.12`'s base and on the #1299/#1281 branch, so that fix neither causes it nor changes it. | [#1310](https://github.com/aallan/vera/issues/1310) | +| A `type` alias whose name is also a registered ADT's compiles at the ADT's **pointer width**, not the alias body's. `type Option = Int;` with a `@Option` parameter is `vera check`-green and `vera verify`-green (4 Tier 1) and then dies at `vera run` with `Invalid input WebAssembly code at offset 79: type mismatch: expected i64, found i32`: codegen emits `(param $p0 i32)`, the ADT's heap pointer, where the checker resolved the alias body and typed the slot `Int`. Single file, no modules, and the byte-identical program with the alias renamed `Widget` emits `(param $p0 i64)` and prints 42 — so it is the collision with a registered ADT name, not aliases in general, and it is reachable for every name in `_adt_layouts`: the built-ins `_register_builtin_adts` installs as much as the prelude's. **Loud only by luck of the widths.** `type Ordering = Bool` runs and answers correctly because the alias body and the ADT pointer are both i32, so at matching widths the same resolution disagreement raises no WebAssembly error at all and the body reads a pointer as its aliased value — a silent wrong answer from a check-green, verify-green program is the class this row tracks, and the i64/i32 case above is merely the arm that happens to be caught by validation. Spec §8.4.1 permits the declaration — the prelude's and the built-in data types are ordinary declarations a program may name and shadow, the reserved namespace being the `Vera` prefix alone (E154) — so the fix is to make codegen agree with the checker rather than to reject the program. `vera/naming.py`'s `_resolve_named` already states the branch order (type parameter → primitive → alias, arity-checked → declared ADT); fix direction: route the WASM-width derivation through that one resolver instead of consulting `_adt_layouts` ahead of the alias maps. | [#1309](https://github.com/aallan/vera/issues/1309) | | `json_parse` of a lone-surrogate escape substitutes U+FFFD in the browser and crashes the reference host. `json_parse("{\"k\":\"a\\ud800b\"}")` — whose Vera source is plain ASCII, so nothing refuses it earlier — prints `{"k":"a�b"}` in the browser and kills `vera run` with a `UnicodeEncodeError` traceback natively. Both hosts decode a JSON `\uD800` escape into a string holding an unpaired surrogate, which has no well-formed UTF-8 encoding, and they then part company writing it into WASM memory: `TextEncoder` replaces it with U+FFFD and returns, while `_alloc_string`'s `.encode("utf-8")` raises. Neither host is obviously right — one silently swaps a different valid string for the requested one, the other refuses but through the raw-traceback path [#1302](https://github.com/aallan/vera/issues/1302) tracks. Sibling of [#1306](https://github.com/aallan/vera/issues/1306): both are the same question about `json_parse`'s domain — which inputs it accepts, and which call refuses the ones it should not. The canonical serializers are NOT implicated: `dumps_canonical` emits a raw surrogate where the browser's walk escapes it, but the string boundary eliminates surrogates before either can see one, so the divergence is unreachable from there. Fix direction: rule on whether a Vera `String` is UTF-8 by construction — if it is, `json_parse` refuses the escape on both hosts and says so; if it is not, both hosts need one agreed lossy or WTF-8 representation. | [#1308](https://github.com/aallan/vera/issues/1308) | | The checker resolves a bare call to a SIBLING function's `where` helper. Spec §5 makes a helper local to its parent, and the checker's own `_lookup_function_scoped` implements that — it walks the enclosing frame stack and reads each frame's direct helpers — but it then falls back to `env.lookup_function`, and `vera/registration.py` has recursed every helper into that flat `TypeEnv`. So a top-level `other()` calling `helperx(7)`, where `helperx` is a helper of an unrelated `holder`, is **check-green and verify-green** and then refused by codegen (`Function 'helperx' is not defined in this module and was not found in any imported module`) — the helper is emitted as `holder$where$helperx`, so the bare call has no target. Loud, never a wrong answer. The op-name variant is the one that matters for the #1284 ownership predicate: with the helper named `get` and the sibling reading a `State` cell by bare `get(())`, the checker binds the HELPER and reports `[E202] Argument 0 of 'get' has type Unit, expected Int` where spec §7.4 resolves the operation — so the checker rejects a program codegen compiles correctly, the two tables disagreeing in the CHECKER's direction for the first time. Codegen's `_scoped_fns` (#1299) implements the spec rule; the fix here is a checker change, whose new rejections need their own blast-radius pass. | [#1307](https://github.com/aallan/vera/issues/1307) | | `json_parse` accepts `NaN`, `Infinity` and `-Infinity` on the reference runtime and refuses them in the browser. The reference host calls `json.loads`, whose default `parse_constant` admits all three; the browser host calls `JSON.parse`, which refuses them as RFC 8259 requires. Measured through identical module bytes under both runtimes, `NaN`, `Infinity`, `-Infinity` and a nested `[NaN]` each take the `Ok` arm natively and the `Err` arm in the browser — a §12.9.3 divergence on the shared surface, and one no test covered because every JSON input in the parity battery is RFC-conformant. The hosts also disagree about *which call* refuses: the browser returns a handled `Err` from the parse, while natively the value parses and the following `json_stringify` raises the non-finite refusal [#1293](https://github.com/aallan/vera/issues/1293) installed, which [#1302](https://github.com/aallan/vera/issues/1302)'s host-callback path then presents as a Python traceback. Fix direction: hand `json.loads` a `parse_constant` that rejects, so the reference host refuses at the point RFC 8259 and the browser already do. | [#1306](https://github.com/aallan/vera/issues/1306) | From 00113f9070b5e448f2b8a88bf0d121bcd28685fc Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 11:41:58 +0100 Subject: [PATCH 28/69] Close CodeRabbit wave 1: a vacuous parametrization, a stale enumeration, three smaller ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Major first. `test_two_differing_module_declarations_are_both_reported` took an `a_first` parameter and then compiled a fixture with a hard-coded import order, ending in `del a_first` — so both ids ran the same program and the both-orders claim for that cell was vacuous. The entry's import order now comes from the parameter, and the case additionally asserts that the two E621 reports arrive in that order, which is the one observable that distinguishes the ids: a fixture that lost its order-sensitivity again fails rather than passing twice. Evidence that the parametrization is live: under the M19 mutation (rail asks only the first declarer) BOTH ids go red, and the neighbouring restate+differ cell goes red in the `alib1st` order alone, which is exactly the order that mutation breaks. TESTING.md's "both import orders" row now says what makes the claim true. TESTING.md's check-level prose said "Thirty-nine programs" over a 39-name list while the level table beside it said 40 — the compile-stage negative was missing. It joins that list (now "Forty") in sorted position, and gets its own sentence rather than the "Thirty-two of them" one, which is specifically about negatives whose diagnostic fires at CHECK. The section's other enumerations were swept: the level table, the verify-level list and the skip table were already right. Three smaller ones. `_compiled` in the #1287 fixture discarded the compile result, so every invariance claim there would have gone on passing over a program codegen had started refusing; it now asserts no error diagnostics and the expected export, which fires as intended when pointed at a program that does draw one. `prelude_data_decls()` is `lru_cache`d and handed every caller the same mutable dict — it now returns a `MappingProxyType`, typed `Mapping`. And the injected-DataDecl scan uses `isinstance(tld.decl, ast.DataDecl)` rather than comparing `type(...).__name__`, matching how `prelude_data_decls` itself spells it. Rebased onto 856e723e (#1311). Every count in the documentation is re-derived from `check_doc_counts.py` on the union tree rather than carried over from either side: 11,072 tests across 168 files, 229 conformance programs, 278 corpus programs, and the level table at 41/20/168. Two ungated claims moved with them. The check-level prose reads "Forty-one programs" and lists #1311's `ch08_module_generic_diamond_base` beside this branch's compile-stage negative. And the skipped-tests section understated itself: #1311 added six check/verify-level programs without the seven skip rows they generate, so the total read 95 where the suite skips 102 — the gate compares the sentence against the rows below it, and both were short by the same amount. Rows added, total corrected against a measured run. Co-Authored-By: Claude --- AGENTS.md | 8 +-- CHANGELOG.md | 2 +- CLAUDE.md | 8 +-- FAQ.md | 4 +- README.md | 2 +- ROADMAP.md | 2 +- SKILL.md | 2 +- TESTING.md | 49 +++++++------ docs/SKILL.md | 2 +- docs/index.html | 2 +- docs/index.md | 2 +- docs/llms-full.txt | 14 ++-- docs/llms.txt | 2 +- tests/test_prelude_adt_namespace_1277.py | 88 ++++++++++++------------ tests/test_prelude_decl_stamp_1287.py | 16 ++++- vera/README.md | 4 +- vera/prelude.py | 14 +++- 17 files changed, 125 insertions(+), 96 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 60d279f1..37daba5c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Read `SKILL.md` for the full language reference. It covers syntax, slot referenc ### Conformance programs as reference -The conformance suite in `tests/conformance/` contains 219 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-three negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) instead must *fail* with the E-code in their `expected_error` field, at the stage their `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. +The conformance suite in `tests/conformance/` contains 229 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-three negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) instead must *fail* with the E-code in their `expected_error` field, at the stage their `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. ### Workflow @@ -187,9 +187,9 @@ Each stage is a module with a single public API function (`parse_file`, `transfo pytest tests/ -v # Run all tests (see TESTING.md) pytest tests/test_conformance.py -v # Conformance suite only mypy vera/ # Type-check the compiler -python scripts/check_conformance.py # All 219 conformance programs hold (positives pass; negatives fail with their E-code) +python scripts/check_conformance.py # All 229 conformance programs hold (positives pass; negatives fail with their E-code) python scripts/check_examples.py # All 42 examples must pass -python scripts/check_corpus_canonical.py # All 268 corpus programs in canonical form +python scripts/check_corpus_canonical.py # All 278 corpus programs in canonical form ``` Test helpers follow a pattern: `_check_ok(source)` / `_check_err(source, match)` / `_verify_ok(source)` / `_verify_err(source, match)`. See existing tests for examples. @@ -198,7 +198,7 @@ When implementing a new language feature, write the conformance program *first* ### Invariants -- All 219 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first +- All 229 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first - All 42 examples in `examples/` must pass `vera check` and `vera verify` - `mypy vera/` must be clean - `pytest tests/ -v` must pass diff --git a/CHANGELOG.md b/CHANGELOG.md index b61de39e..1fd8d109 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). All 258 pre-existing conformance and example programs emit byte-identical WAT, and all three gates now have conformance coverage: reverting any one of them turns the suite red. - **E608 no longer refuses two modules' provably distinct generics** ([#1281](https://github.com/aallan/vera/issues/1281)). The flat-namespace collision rail exists because Pass 2.5 emits every imported function under one WASM name — but a generic emits nothing under its bare name, and since [#1274](https://github.com/aallan/vera/issues/1274) its clones live in a namespace chosen per *owner*: `gen$Bool` for a generic that owns the importer's bare name, `mod$$gen$Bool` for one that does not. A diamond where `base` declares a public `forall fn gen` and `mid1` a private one occupies two different namespaces and was refused outright with `Function 'gen' is defined in both imported module 'mid1' and 'base'`, while `vera verify` returned rc=0 on the same program — a loud verify-vs-compile disagreement. The rail now reads the same ownership classification the clone namespace does, and fires only when the pair really can collide: when either declaration is not a top-level generic (a non-generic *is* emitted under the bare `$name`), when both own the bare name, or when some namespace can name both — a module importing two dependencies that each export `gen` resolves its own bare call to one of them, and neither spec §8.5 nor the checker says which: the checker's pick is a set-iteration artefact, and the identical program accepts on one run and is rejected on the next (measured 4/4 over eight runs, stable under a fixed `PYTHONHASHSEED`, and unchanged by the textual order of the two imports), so that shape keeps its refusal rather than compiling a body chosen by coin-flip ([#1304](https://github.com/aallan/vera/issues/1304)). The registration moved with the message, as defence in depth: a qualified-only generic no longer injects a bare `_fn_sigs` or `_fn_ret_type_exprs` entry at all. Those two registries are read *per name* by consumers the clone classification says nothing about — `MonoContext.fn_names`, the [#1207](https://github.com/aallan/vera/issues/1207) shadow guard, and the WASM call-rewrite's return-type lookup — where first-module-wins would make the answer depend on registration order. What actually closes that shape is the #1299 scope narrowing above, which reaches the same consultors through the call site: reverting both withholdings leaves every suite and all 224 conformance programs green. They are kept, and pinned structurally on the tables they act on with one cell each, because nothing but those four consumers' current internals stops any of them from picking a winner. -- **A module's data type no longer empties a prelude one out of every other namespace, and the shape that silently dropped functions is now an error** ([#1277](https://github.com/aallan/vera/issues/1277)). Codegen keeps ONE flat `_adt_layouts` map while the checker gives every namespace the prelude's data types from the start, and the two halves of that gap failed differently. **Membership**: `_adt_members_in_scope` recovered global infrastructure by SUBTRACTING what the namespaces declare from the registered layouts, which is sound only while "declared by a namespace" and "global infrastructure" are disjoint — and §8.4.1 makes them overlap on purpose, since the prelude's data types are ordinary public declarations a program may name and shadow. So one file's `data Json` removed `Json` from the member set of every OTHER namespace, including the entry program's, while the checker's `TypeEnv` carried it in all of them; measured as a straight disagreement, `Json` a data type in a module's namespace for the checker and not for codegen. The Pass-0.5 built-in snapshot unioned in as a floor could not protect the four demand-injected prelude ADTs, because it is taken before Pass 1.2 injects them — the same asymmetry [#1253](https://github.com/aallan/vera/issues/1253) fixed, one layer down. The floor is now stated positively rather than recovered by elimination: `vera.prelude.prelude_adt_names()` parses the prelude's own data blocks with the same parser `inject_prelude` uses, so a new prelude ADT joins the set by being written, and a differential holds the two against each other. Scoping the subtraction per namespace instead — the other direction the issue left open — is refuted by `tests/test_adt_membership_scope_1253.py`, which it re-opens: a sibling module's ADT would become infrastructure for every namespace but its own. **Contention**: where a MODULE declares one of the prelude's data type names with a DIFFERENT SHAPE and the prelude is also compiling its own, the two contend for the one layout slot and the module's wins. The prelude's ADT was then never registered, its own combinators hit `unknown constructor` (an `[E602]` inside ``), and every user function touching the type was dropped behind an `[E620]` cascade — all of it reported as WARNINGS, so a `vera check`-green program compiled with **exit 0** to a module with a function silently missing from its exports, and nothing named the declaration that caused it. That is now **E621**, an error located at the module's declaration in the module's own file, refused by the same Pass-1.9 severity gate `E608`/`E609`/`E610` use. It covers all eight of the prelude's data types, which required reading the DECLARATIONS rather than the registered layouts: the layout harvest skips a built-in name outright, because the throwaway registrar holds `Option`, `Result`, `Ordering` and `UrlParts` for every module whether it declares them or not, so a layout-keyed rail saw `data Json` and never `data Option`. The two halves differ only in when the prelude is present — the demand-injected four not until the entry program uses them, the always-injected four in every program — so a differently-shaped module `data Ordering` contends unconditionally, which upgrades that shape from an `[E602]`/`[E620]` cascade to one instruction. What decides contention is the two declarations' SHAPES: the same constructors, in the same order (the tag is the position), with the same field types, type parameters compared positionally. A module that restates the prelude's type shares the one layout — measured legal for all eight, and kept legal, which is what stops the rail from becoming the reservation §8.4.1 forbids. That is not a hypothetical: `examples/vera/collections.vera` declares `public data Option { None, Some(T) }` and `examples/modules.vera` imports it, so a rail that fired on the name alone refuses a shipped example — `vera compile` on `examples/modules.vera` returns E621 under that mutation, which `scripts/check_e602_clean.py` catches as a `COMPILE_ERROR` (measured; `check_examples.py` does not, running only `check` and `verify`). The rail's first form refused four of the synthetic restatements. Every declaring module is asked, not the first: a library that restates the prelude's `Ordering` otherwise answered for a sibling declaring a different one, so with the restating module imported first the sibling's contention went unseen — check-green, exit 0, the caller silently dropped — and the reverse import order caught it. An order-dependent rail is not a rail, and the battery now carries both orders. The module-versus-module pair for a name the prelude does NOT provide stays E609's, which a control pins. The acceptance battery is a parameterized test over all eight names in both shapes plus the restatement control, asserting that no cell reports `[E602]`/`[E620]` and that no zero-exit compile is missing a function, so the four-of-eight coverage the rail started with cannot return silently; `ch08_module_prelude_adt_contention_rejected` pins the same refusal at conformance level, paired with the positive that imports the same module and never names the type. The conformance manifest gained an `expected_error_stage` key for it (`"check"`, the default, or `"compile"`): a compile-stage negative asserts that the program type-checks CLEANLY and is then refused by `vera compile` with the declared code, which is the property a codegen-phase diagnostic exists for and which the check-only negative path could not express. Reserving the prelude's names is not the fix and is not done: §8.4.1 forbids it. +- **A module's data type no longer empties a prelude one out of every other namespace, and the shape that silently dropped functions is now an error** ([#1277](https://github.com/aallan/vera/issues/1277)). Codegen keeps ONE flat `_adt_layouts` map while the checker gives every namespace the prelude's data types from the start, and the two halves of that gap failed differently. **Membership**: `_adt_members_in_scope` recovered global infrastructure by SUBTRACTING what the namespaces declare from the registered layouts, which is sound only while "declared by a namespace" and "global infrastructure" are disjoint — and §8.4.1 makes them overlap on purpose, since the prelude's data types are ordinary public declarations a program may name and shadow. So one file's `data Json` removed `Json` from the member set of every OTHER namespace, including the entry program's, while the checker's `TypeEnv` carried it in all of them; measured as a straight disagreement, `Json` a data type in a module's namespace for the checker and not for codegen. The Pass-0.5 built-in snapshot unioned in as a floor could not protect the four demand-injected prelude ADTs, because it is taken before Pass 1.2 injects them — the same asymmetry [#1253](https://github.com/aallan/vera/issues/1253) fixed, one layer down. The floor is now stated positively rather than recovered by elimination: `vera.prelude.prelude_adt_names()` parses the prelude's own data blocks with the same parser `inject_prelude` uses, so a new prelude ADT joins the set by being written, and a differential holds the two against each other; the cached `prelude_data_decls()` behind it hands back a read-only mapping, since one cached object is shared by every caller in the process. Scoping the subtraction per namespace instead — the other direction the issue left open — is refuted by `tests/test_adt_membership_scope_1253.py`, which it re-opens: a sibling module's ADT would become infrastructure for every namespace but its own. **Contention**: where a MODULE declares one of the prelude's data type names with a DIFFERENT SHAPE and the prelude is also compiling its own, the two contend for the one layout slot and the module's wins. The prelude's ADT was then never registered, its own combinators hit `unknown constructor` (an `[E602]` inside ``), and every user function touching the type was dropped behind an `[E620]` cascade — all of it reported as WARNINGS, so a `vera check`-green program compiled with **exit 0** to a module with a function silently missing from its exports, and nothing named the declaration that caused it. That is now **E621**, an error located at the module's declaration in the module's own file, refused by the same Pass-1.9 severity gate `E608`/`E609`/`E610` use. It covers all eight of the prelude's data types, which required reading the DECLARATIONS rather than the registered layouts: the layout harvest skips a built-in name outright, because the throwaway registrar holds `Option`, `Result`, `Ordering` and `UrlParts` for every module whether it declares them or not, so a layout-keyed rail saw `data Json` and never `data Option`. The two halves differ only in when the prelude is present — the demand-injected four not until the entry program uses them, the always-injected four in every program — so a differently-shaped module `data Ordering` contends unconditionally, which upgrades that shape from an `[E602]`/`[E620]` cascade to one instruction. What decides contention is the two declarations' SHAPES: the same constructors, in the same order (the tag is the position), with the same field types, type parameters compared positionally. A module that restates the prelude's type shares the one layout — measured legal for all eight, and kept legal, which is what stops the rail from becoming the reservation §8.4.1 forbids. That is not a hypothetical: `examples/vera/collections.vera` declares `public data Option { None, Some(T) }` and `examples/modules.vera` imports it, so a rail that fired on the name alone refuses a shipped example — `vera compile` on `examples/modules.vera` returns E621 under that mutation, which `scripts/check_e602_clean.py` catches as a `COMPILE_ERROR` (measured; `check_examples.py` does not, running only `check` and `verify`). The rail's first form refused four of the synthetic restatements. Every declaring module is asked, not the first: a library that restates the prelude's `Ordering` otherwise answered for a sibling declaring a different one, so with the restating module imported first the sibling's contention went unseen — check-green, exit 0, the caller silently dropped — and the reverse import order caught it. An order-dependent rail is not a rail, and the battery now carries both orders. The module-versus-module pair for a name the prelude does NOT provide stays E609's, which a control pins. The acceptance battery is a parameterized test over all eight names in both shapes plus the restatement control, asserting that no cell reports `[E602]`/`[E620]` and that no zero-exit compile is missing a function, so the four-of-eight coverage the rail started with cannot return silently; `ch08_module_prelude_adt_contention_rejected` pins the same refusal at conformance level, paired with the positive that imports the same module and never names the type. The conformance manifest gained an `expected_error_stage` key for it (`"check"`, the default, or `"compile"`): a compile-stage negative asserts that the program type-checks CLEANLY and is then refused by `vera compile` with the declared code, which is the property a codegen-phase diagnostic exists for and which the check-only negative path could not express. Reserving the prelude's names is not the fix and is not done: §8.4.1 forbids it. - **The prelude's declaration-index block no longer depends on what the main file declares** ([#1287](https://github.com/aallan/vera/issues/1287)). `_stamp_decl_order` guarded its PRELUDE write on `_decl_order`, the ACTIVE (main-file) namespace — but `_prelude_decl_order` is not a namespace: `_module_alias_scope` builds every module's index space as `{**prelude, **module_own}`, so it is the base layer under all of them and its contents are a fact about what `inject_prelude` laid down. A main-file `type Option = Int` is accepted (§8.4.1 again) and, being an alias rather than a `data`, does not suppress the prelude's own `data Option`, so the guard fired on the prelude stamp: `Option` was left out of the block entirely, and every later prelude declaration shifted one place earlier because the skipped stamp never advanced the counter. Inside a module namespace the prelude's `Option` then reached `AliasEnv.data_types` at `_BUILTIN_DECL_INDEX` — below `_PRELUDE_DECL_BASE`, so ordered ahead of every other prelude declaration rather than among them — which is exactly the cross-namespace leak `_decl_order` and `_module_decl_order` were split apart to prevent. Latent at emission: that map changes a rendering only for `Decimal` and the single `REMOVED_ALIASES` entry `Float`, and no prelude ADT is either, so no WAT moves; the defect is the wrong value reaching the consumer. The prelude write is now unconditional and the ACTIVE space still takes the main file's stamp, so the shadow keeps winning its own namespace. Pinned as an invariance — the same program with and without the shadowing alias must stamp an identical prelude block — with the main-namespace control that a fix stamping `_decl_order` unconditionally would fail. diff --git a/CLAUDE.md b/CLAUDE.md index afdc0416..848c4ad7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,9 +58,9 @@ VERA_JS_COVERAGE=1 pytest tests/test_browser.py -v # Browser tests with JS cove VERA_EAGER_GC=1 vera run file.vera # Force GC on every alloc (see ENVIRONMENT.md, debug knob for #593-class GC-rooting bugs) mypy vera/ # Type-check the compiler itself -python scripts/check_conformance.py # Verify all 219 conformance programs (positives pass their level; negatives fail with their expected_error E-code) +python scripts/check_conformance.py # Verify all 229 conformance programs (positives pass their level; negatives fail with their expected_error E-code) python scripts/check_examples.py # Verify all 42 examples parse + check + verify -python scripts/check_corpus_canonical.py # Verify all 268 corpus programs are in canonical form (vera fmt) +python scripts/check_corpus_canonical.py # Verify all 278 corpus programs are in canonical form (vera fmt) python scripts/check_examples_readme.py # Verify vera run commands in examples/README.md python scripts/check_spec_examples.py # Verify spec code blocks parse python scripts/check_readme_examples.py # Verify README code blocks parse @@ -92,7 +92,7 @@ See [`TOOLCHAIN.md`](TOOLCHAIN.md) for the CLI cookbook — driving the toolchai - `vera/` — Reference compiler: grammar, parser, AST, transformer, type checker, verifier, codegen, CLI - `examples/` — 42 example Vera programs (all must pass `vera check` and `vera verify`) - `tests/` — Test suite (unit tests + conformance suite) -- `tests/conformance/` — 219 conformance programs validating every language feature against the spec +- `tests/conformance/` — 229 conformance programs validating every language feature against the spec - `scripts/` — CI and validation scripts ## Writing Vera code @@ -129,7 +129,7 @@ Before changing code — **adding or removing** — write the test that proves y ## What not to break - Pre-commit hooks run mypy + pytest + conformance suite + example validation on every commit -- All 219 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first +- All 229 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first - All 42 examples in `examples/` must pass `vera check` and `vera verify` - Version must stay in sync across `pyproject.toml`, `vera/__init__.py`, `docs/index.html`, `README.md`, and `uv.lock` (gated by `scripts/check_version_sync.py`); CHANGELOG.md must also carry a matching `## [X.Y.Z]` section - All tests must pass: `pytest tests/ -v` diff --git a/FAQ.md b/FAQ.md index 3778afd8..bca783d2 100644 --- a/FAQ.md +++ b/FAQ.md @@ -236,7 +236,7 @@ None of this is Vera-specific, but it validates the design choices. The thesis i This is a real concern. LLMs are trained on trillions of tokens of Python, TypeScript, and JavaScript. A MojoBench study (NAACL 2025) found that even fine-tuned models achieved only 30–35% improvement over base models on Mojo code generation, illustrating the cold-start problem for new languages. -Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (219 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification. +Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (229 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification. ## How does Vera compare to Dafny / Lean / Koka / F*? @@ -279,7 +279,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 10,884 tests, including a 219-program conformance suite +- 11,072 tests, including a 229-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/README.md b/README.md index 23c08cfa..9a319db3 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ cp /path/to/vera/SKILL.md ~/.claude/skills/vera-language/SKILL.md ## Project status -Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 10,991 tests, 95% Python code coverage, 226 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. +Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 11,072 tests, 95% Python code coverage, 229 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. The reference compiler — parser, AST, type checker, contract verifier (Z3), WASM code generator, module system, browser runtime, and runtime contract insertion — is working. The language specification is in draft across [14 chapters](spec/). diff --git a/ROADMAP.md b/ROADMAP.md index 50023846..b8379562 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,7 +12,7 @@ Ordering derives from the design principles ([DESIGN.md](DESIGN.md)): verificati ## Where we are -10,991 tests, 226 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. +11,072 tests, 229 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. ## Stage 19 — The verification completeness sprint diff --git a/SKILL.md b/SKILL.md index b5d1b57b..1cb4fc11 100644 --- a/SKILL.md +++ b/SKILL.md @@ -2454,7 +2454,7 @@ public fn main(@Unit -> @Unit) ## Conformance Suite -The `tests/conformance/` directory contains 226 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. +The `tests/conformance/` directory contains 229 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. Each program is organized by spec chapter (`ch01_int_literals.vera`, `ch04_match_basic.vera`, `ch07_state_handler.vera`, etc.) and the `manifest.json` file maps features to programs. When you need to see how a specific construct works, check the conformance program before reading the spec. diff --git a/TESTING.md b/TESTING.md index 1ca55b8b..31b0f2f4 100644 --- a/TESTING.md +++ b/TESTING.md @@ -6,9 +6,9 @@ This is the single source of truth for Vera's testing infrastructure, coverage d | Metric | Value | |--------|-------| -| **Tests** | 10,884 across 166 files (~155,000 lines of test code; 10,714 passed + 26 stress, 144 skipped) | +| **Tests** | 11,072 across 168 files (~155,000 lines of test code; 10,893 passed + 26 stress, 153 skipped) | | **Compiler code coverage** | 95% Python, 87% JavaScript (CI minimum: 80%) | -| **Conformance programs** | 219 programs across 9 spec chapters, validating every language feature | +| **Conformance programs** | 229 programs across 9 spec chapters, validating every language feature | | **Example programs** | 42, all validated through `vera check` + `vera verify` | | **Spec code blocks** | 189 parseable blocks from 14 spec chapters: 92 parse, 86 type-check, 85 verify (the rest carry inline `vera:skip` annotations, #538) | | **README code blocks** | 4 Vera blocks (4 validated, 0 annotated) | @@ -39,7 +39,7 @@ VERA_EAGER_GC=1 pytest tests/test_codegen_closures.py::TestClosureReturnShadowPu mypy vera/ # strict mode # Validation scripts -python scripts/check_conformance.py # conformance suite (219 programs, see manifest.json) +python scripts/check_conformance.py # conformance suite (229 programs, see manifest.json) python scripts/check_examples.py # 42 example programs python scripts/check_spec_examples.py # spec code blocks python scripts/check_readme_examples.py # README code blocks @@ -79,8 +79,8 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_handler_op_ownership_1284.py` | 15 | 419 | #1284 — whose declaration a bare `get`/`put` call site denotes. The checker resolves user-fn-first (pinned directly: an over-applied `get` under a `handle[State]` reports the USER signature's arity), and codegen used to answer that question twice more and differently — the declared-effect row withheld the op when a function owned the name, the handler expression overwrote unconditionally. Four shapes from check-green source, each asserted on the CHECKER's value and on the dispatch target in the emitted WAT: a handled body returning the cell instead of the function's answer (silently 5 for 4), a `@Bool`-returning user `get` whose module WASM validation rejected, same-family nesting refused outright with a spurious `[E602]` naming a State operation the source never contained, and different-family nesting emitting the enclosing cell's getter at the wrong width. A parametrized differential runs all five shapes (the four plus a user `put`) as one table with each case's pre-fix behaviour recorded, so a case that stops distinguishing the two answers is visible rather than vacuous; the controls — an unshadowed handler and an unshadowed declared row, both of which must still reach the intrinsics — are what a fix that simply stopped installing the ops would fail, and `new(State)` under a shadowed op name pins that the #1285 family registry composes with this | | `test_new_state_family_1285.py` | 9 | 323 | #1285 — which cell `new(State)` reads under a multi-`State` effect row. `old()` has been family-keyed since #1205/#1209 while `new()` read the name-keyed op registry, so the two sides of one `ensures` clause read different cells: `effects(, State>)` with `ensures(new(State) == …)` was check-green and verify-green, put `state_get_Int`'s i64 into the Bool comparison's `i32.eq`, and died at load. Three multi-row cases — the width-mismatched shape that could not load, an `Int`/`Nat` pair that loaded and answered about the wrong cell, and `old()` beside `new()` of one family, whose unchanged-cell claim the runtime refuted on a contract the verifier had discharged — plus the single-`State` and alias-spelled controls the whole existing corpus exercises. Each cell is seeded from a caller's handler at a value the other cell is not holding, so a wrong-cell read cannot coincide with the right answer, and a deliberately false postcondition asserts the Tier 3 runtime check really traps, without which every "the program runs" assertion here would prove nothing | | `test_adt_membership_scope_1253.py` | 5 | 334 | #1253 — a checker↔codegen DIFFERENTIAL over one module's slot table. `_adt_layouts` is one map across every absorbed namespace, so a sibling module's ADTs were members of a module that never imported them while the checker kept the name opaque: `['Array', 'Array']` against `['Array', 'Array']` for the same signature. Each case renders the module's parameters through `vera.naming` twice — once against the environment the checker binds that module's declarations in (built by the production `_modules_visible_to` + `check_program` path, not a rebuild of it) and once against codegen's `_alias_env` inside `_module_alias_scope` — and asserts both the agreement and the checker's own value, so an alignment on the WRONG name still fails. Three membership cases (an unimported public sibling, a private sibling, and the imported positive control that is green before and after — what separates scoping the membership from erasing cross-module ADTs) plus the entry program's own view, which must keep seeing the ADT it imports by name | -| `test_prelude_decl_stamp_1287.py` | 4 | 215 | #1287 — the prelude's declaration-index block is a fact about the prelude. `_stamp_decl_order` guarded the PRELUDE write on `_decl_order`, the active (main-file) namespace, so a main-file `type Option = Int` — accepted under §8.4.1, and not a `data`, so it does not suppress the prelude's own `Option` — made the guard fire and left `Option` out of `_prelude_decl_order` entirely, with every later prelude declaration shifted one place earlier because the counter never advanced. That map is the base layer under every module's index space (`{**prelude, **module_own}`), so the wrong index reached `AliasEnv.data_types` as `_BUILTIN_DECL_INDEX`. Stated as an INVARIANCE — the same program with and without the shadowing alias must stamp an identical prelude block — plus the module-namespace index it feeds, and a control that the main file's own stamp still wins its own namespace (which a fix stamping `_decl_order` unconditionally would break) | -| `test_prelude_adt_namespace_1277.py` | 49 | 808 | #1277 — one file's `data Json` must not evict the prelude's from another namespace, and a module declaration contending with a prelude one must be loud. Three halves, pinned by disjoint cases so a regression in any is attributable. **Acceptance battery**: all eight prelude ADT names × {module declares it alone, entry also uses the prelude's}, asserting that no cell reports `[E602]`/`[E620]` and that a cell which does not report `[E621]` emits every public function the entry declares — the silent-drop check, and the guard against the rail's original four-of-eight coverage returning (the layout harvest skips a built-in name, so a layout-keyed rail saw `data Json` and never `data Option`). Plus two-declaring-module cells in both import orders (restate+differ, both-differ, both-restate, and a non-prelude control that must stay E609's), because the rail asks every declarer and a first-wins lookup made it order-dependent. Plus the restatement control for all eight: a module that restates the prelude's shape shares the one layout, compiles and runs, and must not be refused — measured legal at the branch point, and refused by the rail's first form for four of them. **Rail detail**: severity, the module's own file and line, the description naming the type and the module, the empty exports, and `cmd_compile` returning 1 over a `cmd_check`-green program. **Floor**: the checker registers the prelude ADTs in every `TypeEnv` unconditionally, so codegen's membership must too; asserted as a differential against the checker's own `data_types` in a module namespace, and on the entry namespace's member set for the issue's measured shape. `prelude_adt_names()` is compared against `inject_prelude` itself, and `data_decl_shape` is pinned on both directions that matter — a renamed type parameter is the same layout, a reordered constructor is not | +| `test_prelude_decl_stamp_1287.py` | 4 | 227 | #1287 — the prelude's declaration-index block is a fact about the prelude. `_stamp_decl_order` guarded the PRELUDE write on `_decl_order`, the active (main-file) namespace, so a main-file `type Option = Int` — accepted under §8.4.1, and not a `data`, so it does not suppress the prelude's own `Option` — made the guard fire and left `Option` out of `_prelude_decl_order` entirely, with every later prelude declaration shifted one place earlier because the counter never advanced. That map is the base layer under every module's index space (`{**prelude, **module_own}`), so the wrong index reached `AliasEnv.data_types` as `_BUILTIN_DECL_INDEX`. Stated as an INVARIANCE — the same program with and without the shadowing alias must stamp an identical prelude block — plus the module-namespace index it feeds, and a control that the main file's own stamp still wins its own namespace (which a fix stamping `_decl_order` unconditionally would break) | +| `test_prelude_adt_namespace_1277.py` | 49 | 810 | #1277 — one file's `data Json` must not evict the prelude's from another namespace, and a module declaration contending with a prelude one must be loud. Three halves, pinned by disjoint cases so a regression in any is attributable. **Acceptance battery**: all eight prelude ADT names × {module declares it alone, entry also uses the prelude's}, asserting that no cell reports `[E602]`/`[E620]` and that a cell which does not report `[E621]` emits every public function the entry declares — the silent-drop check, and the guard against the rail's original four-of-eight coverage returning (the layout harvest skips a built-in name, so a layout-keyed rail saw `data Json` and never `data Option`). Plus two-declaring-module cells in both import orders — the entry's import order is derived from the parametrization, and the both-differ cell asserts the two reports arrive in that order, so the pair cannot quietly become one program compiled twice — covering restate+differ, both-differ, both-restate, and a non-prelude control that must stay E609's, because the rail asks every declarer and a first-wins lookup made it order-dependent. Plus the restatement control for all eight: a module that restates the prelude's shape shares the one layout, compiles and runs, and must not be refused — measured legal at the branch point, and refused by the rail's first form for four of them. **Rail detail**: severity, the module's own file and line, the description naming the type and the module, the empty exports, and `cmd_compile` returning 1 over a `cmd_check`-green program. **Floor**: the checker registers the prelude ADTs in every `TypeEnv` unconditionally, so codegen's membership must too; asserted as a differential against the checker's own `data_types` in a module namespace, and on the entry namespace's member set for the issue's measured shape. `prelude_adt_names()` is compared against `inject_prelude` itself, and `data_decl_shape` is pinned on both directions that matter — a renamed type parameter is the same layout, a reordered constructor is not | | `test_import_visibility_entry_point_1244.py` | 6 | 269 | #1244 — `vera check` reports the same diagnostics whether it was given a module or a file that imports it. Registration alone says what a module DECLARES; the importer never checked its bodies, so a name a module never imported was rejected standalone (E200, §8.5.1) and accepted in silence through an importer. Written as EQUALITY between the two entry points rather than as "the importer warns", because the property is agreement — a future change making the standalone verdict lenient would satisfy a one-sided assertion and must fail here on the standalone leg. Six cases: the leaked unimported name, the honest control that imports what it uses (green before and after, so the new body check is the visibility rule rather than a blanket rejection of cross-module programs), the issue's type-error-through-importer shape (an `@Int` call bound to a `@Bool` slot: check-clean, Tier-1, failing at compile), a diamond proving each module is reported ONCE (the body check is memoised by path across nested checkers), and both entry points into an import cycle proving the memo terminates it | | `test_clone_body_declaring_module_1241_1243.py` | 5 | 344 | #1241 + #1243 — an imported generic's clone body resolves its bare calls in the DECLARING module, on both sides. The verifier's lexical lookup fell through to the importer's registry (`_declaring_module_scope` swapped the naming env, source and file but not the function registry) and codegen's clone-emission door was the one door that did not thread the module's intra-rename map, so `glib`'s `gen` called the importer's `need`. The two halves are one routing rule, and the tests are written so neither passes alone: each case asserts the `vera verify` verdict AND the `vera run` value together, so the verifier half alone (which makes verify clean while the compiled program still traps on the postcondition it just proved — the measured false Tier-1) fails the same test the codegen half alone (right value, verify still refusing) fails. Every expected value comes from the module verified and run STANDALONE, never from what the importer produces. Shapes: a private direct callee, a two-hop private chain, the type-discriminating pair (`@Int` vs `@Bool` — check-green source that emitted invalid WASM), and the unshadowed-callee control that was correct before and after, which is what pins the defect to the SHADOWED name rather than to cross-module calls in general | | `test_module_generic_namespace_1274.py` | 24 | 1,015 | #1274 — a module generic that does not own the importer's bare name is reached under `mod$$name`. Pre-fix only PRIVATE module generics were routed that way (#1000), so a PUBLIC one collided with the importer's same-named generic in the clone-name space: both files' `gen2` mangled to one `gen2$Bool`, one overwrote the other, and the module's own body ran the importer's — a **false Tier-1** (`check`/`verify` clean, the module's proved `ensures` violated at run: 999 where the declaring module answers 111). The full visibility matrix (module generic × importer generic), the import-filter dimension (out-of-filter, in-filter, wildcard), the unshadowed-out-of-filter cell that assembled to `unknown func $gen2`, and a type-discriminating shape whose two clones have different WAT result types. Each cell asserts the verify VERDICT and the runtime VALUE together in one test — a clean verify beside a violated postcondition IS the bug, so splitting them across sibling tests would let it hide — against the standalone library as oracle, and re-checks that the importer's own generic still answers its own value. The per-module both-sides differential lives in `test_monomorphize_differential.py`. Two further families joined after the adversarial round: the module→module **hop** — a module's bare call to a DIFFERENT module's qualified-only generic, which the per-module classification never rerouted, in both a LOUD spelling (a contract pins the answer, so a captured call traps) and a SILENT one (every contract admits both answers, so only the value distinguishes them) plus the two-hop shape where the entry never imports the declaring module at all; and the **shared-input** pair, which pins that the two sides compute the importer's occupied bare names identically — codegen reads them after Pass 0's helper renames, the verifier from the pre-transform AST, and a non-generic `where`-helper named `gen2` made the same imported generic bare-name-owning on one side and qualified-only on the other. The idempotence of that derivation is asserted directly across BOTH Pass-0 transforms and their composition — over a fixture carrying every helper shape it distinguishes (non-generic under non-generic, under a generic parent, under a generic helper, and a generic helper), since a fixture missing one would let a partial assertion look total — with each shape's membership pinned individually beside it, because idempotence alone would hold for a derivation that answered the same WRONG set every time. Two more families close the visibility dimension: the **transitive** one, driven through the production `ModuleResolver` (a hand-built `ResolvedModule` defaults `direct=True` and would never reach the path), asserting that a module reached only transitively has ALL its generics qualified-only — the entry's namespace does not hold them at all; and the **user-written qualified call** (`deep::gen(true)` where the importer declares its own `gen`), which must key its instantiation to the module's declaration rather than to whoever owns the bare name | @@ -99,7 +99,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_checker_errors.py` | 73 | 1,196 | Error codes, resolution-coverage diagnostics, contracts, error accumulation (#420 split); cyclic type aliases incl. #1059 self-reference through a type argument (`Future`, mutual `Future`/`Future`, `Array`) rejected E132 | | `test_checker_builtins_collections.py` | 97 | 848 | Map / Set / Decimal / Json / Html / Http / Inference built-in type-checking (#420 split) | | `test_checker_builtins_strings.py` | 122 | 945 | String / numeric / type-conversion / float-predicate / string-search / markdown / regex built-in type-checking, removed-legacy-name regression (#420 split) | -| `test_obligations.py` | 722 | 1,741 | Reified proof obligations + warm `VerificationSession` (#222 Phase A): full-corpus differential oracle (warm session == cold `verify()` on diagnostics, summary, and obligation stream, plus warm-twice determinism, across all 42 examples and every verify/run-level conformance program), summary↔obligation tier-bookkeeping consistency (including the #967 `total == tier1_verified + tier3_runtime` leg, plus a focused self-consistency pin on the three call-demotion examples), the #1242 stream partition — over a corpus widened to every conformance program that type-checks, at any level, `len(obligations) == total + violated + tier3_unguarded` and every status is one of the documented five, with the vocabulary read from the `ObligationStatus` Literal so a sixth member fails rather than vanishing from the counts — per-kind unit tests (requires / ensures / decreases / nat_sub / call_pre statuses, counterexamples, error codes), content-key stability + same-text-two-sites span disambiguation, session solver reuse, type-error short-circuit, ADT-registry resync between programs; plus the Phase B incremental suite — identical-source full replay, callee-body-edit replays callers while callee-contract-edit invalidates them, span-shift and ADT-edit conservative invalidation, cross-program isolation, timeout-status never cached (monkeypatched solver), FIFO eviction bound; plus the #727 dedup pin — a violating call in a let RHS records exactly one E501 diagnostic and one call_pre obligation; plus the #1208 call-site rendering pin — a PARAMETERISED callee slot substitutes into the E501 message and its fix instead of falling back to the generic wording | +| `test_obligations.py` | 750 | 1,741 | Reified proof obligations + warm `VerificationSession` (#222 Phase A): full-corpus differential oracle (warm session == cold `verify()` on diagnostics, summary, and obligation stream, plus warm-twice determinism, across all 42 examples and every verify/run-level conformance program), summary↔obligation tier-bookkeeping consistency (including the #967 `total == tier1_verified + tier3_runtime` leg, plus a focused self-consistency pin on the three call-demotion examples), the #1242 stream partition — over a corpus widened to every conformance program that type-checks, at any level, `len(obligations) == total + violated + tier3_unguarded` and every status is one of the documented five, with the vocabulary read from the `ObligationStatus` Literal so a sixth member fails rather than vanishing from the counts — per-kind unit tests (requires / ensures / decreases / nat_sub / call_pre statuses, counterexamples, error codes), content-key stability + same-text-two-sites span disambiguation, session solver reuse, type-error short-circuit, ADT-registry resync between programs; plus the Phase B incremental suite — identical-source full replay, callee-body-edit replays callers while callee-contract-edit invalidates them, span-shift and ADT-edit conservative invalidation, cross-program isolation, timeout-status never cached (monkeypatched solver), FIFO eviction bound; plus the #727 dedup pin — a violating call in a let RHS records exactly one E501 diagnostic and one call_pre obligation; plus the #1208 call-site rendering pin — a PARAMETERISED callee slot substitutes into the E501 message and its fix instead of falling back to the generic wording | | `test_verifier_contracts.py` | 95 | 898 | Z3 verification over the example corpus, trivial/ensures/if-else/let/multi-clause contracts, counterexamples, tier classification, arithmetic, verification summaries, Diverge effect, edge cases, string-length + string-predicate verification (#839 split) | | `test_verifier_nat_obligations.py` | 82 | 1,743 | **`@Nat` subtraction underflow obligation** (#520 — Path-A discharge via requires/path-conditions/path-aware Z3 refutation, pure-literal exclusion, Int-Int and Nat-Int exemptions) and **`@Nat` binding-site narrowing obligation** (#552/#747/#749 — Tier-1 `value >= 0` at let/call-arg/effect-op-arg/ctor-field/match-bind/destructure narrowing — a concrete site classifies `tier3_runtime` (codegen-guarded) while the effect-op argument and generic-instantiated constructor field classify `E504` (obligated but unguarded, #754/#757) whose rationale names its actual cause — an untranslatable value — rather than the untranslatable-or-timeout conflation #1251 removed, walker-recursion pins, `_narrows_into_nat` verifier/codegen soundness parity; PR #972 clone-instantiated side-table substitution — a `Some(@T)` bind in an `Option`-instantiated clone is no narrowing, genuine clone-path narrowings still obligated); #1201 — a builtin `Tuple` parameter's match-bound components carry their declared component facts (a valid ensures over one proves instead of falsely violating) and an `Int` component bound as `@Nat` fires one loud `E503` per component, both mutation-caught (#839 split) | | `test_verifier_primitive_ops.py` | 39 | 662 | **Primitive-operation safety obligations** (#680) — division/modulo by-zero `E526` and array-index-bounds `E527`, the in-bounds/out-of-bounds two-check with float-exemption, honest Tier-3 for opaque lengths, off-by-one and lower-bound pins, De Bruijn-correct fix hints (#839 split) | @@ -117,7 +117,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_soundness_392.py` | 36 | 584 | #392 audit batches 1–2 — verifier soundness/completeness fixes: signed div/mod truncate toward zero (#799), body `assert(P)` carries a Tier-1 obligation (#800), divisions in contract predicates carry a `div_zero` obligation (#801), and the #804 assume-half of #800's `assert` rule — a prior `assert`/`assume` discharges later obligations (including a later call's precondition) + the postcondition at Tier 1, removing false E501/E503/E500/E505 | | `test_int_overflow.py` | 6 | 143 | #798 — `@Int`/`@Nat` arithmetic-overflow obligations (part of the #392 `smt.py` soundness audit): `+`/`-`/`*` on `@Int`/`@Nat` now emit an `int_overflow` obligation (the analog of `nat_sub`/`div_zero`) rather than modelling the operands as Z3's unbounded integers, so a `ensures(@Int.result > @Int.0)` over `@Int.0 + 1` no longer proves a contract the i64/u64 runtime violates under two's-complement wraparound. Unbounded operands leave the obligation undischarged (Tier-3, runtime-guarded); operand bounds that prove the result stays in range discharge it at Tier 1 | | `test_int_overflow_codegen.py` | 62 | 718 | #798 Stage 3 — runtime overflow-trap codegen: the codegen emits a guard at *exactly* the `@Int`/`@Nat` `+`/`-`/`*` sites the verifier obligates, so `vera run`/`vera compile` programs trap on overflow instead of silently wrapping at the i64/u64 boundary. #808 wired the guard to the `vera.overflow_trap` host import, so the trap now classifies `kind="overflow"` (carrying the overflow Fix paragraph) rather than the generic `unreachable`; `TestOverflowTrapKind808` pins that, with controls proving the #520 `nat_sub` underflow and #813 `@Nat`→`@Int` widen guards still classify `unreachable` | -| `test_int_overflow_differential.py` | 243 | 398 | #798 Stage 3 verifier↔codegen classification differential (cross-component soundness rule): the codegen overflow guard must fire at exactly the sites the verifier obligates *and* classify each site's operand type (`@Int` i64 vs `@Nat` u64) identically — else a Tier-1-clean program traps spuriously or a wrapping op slips through unguarded. Over a corpus exercising all five operand combos plus the literal-left ambiguity (a naive codegen mis-classifies it as `@Nat`), asserts the verifier's per-site gated classification equals the codegen's site for site, both sides driven by the same `ast.span_key` | +| `test_int_overflow_differential.py` | 252 | 398 | #798 Stage 3 verifier↔codegen classification differential (cross-component soundness rule): the codegen overflow guard must fire at exactly the sites the verifier obligates *and* classify each site's operand type (`@Int` i64 vs `@Nat` u64) identically — else a Tier-1-clean program traps spuriously or a wrapping op slips through unguarded. Over a corpus exercising all five operand combos plus the literal-left ambiguity (a naive codegen mis-classifies it as `@Nat`), asserts the verifier's per-site gated classification equals the codegen's site for site, both sides driven by the same `ast.span_key` | | `test_nat_int_widening.py` | 36 | 602 | #813 — `@Nat -> @Int` widening coercion obligation (dual of #552 `nat_bind`, part of the #392 soundness audit): a `@Nat` in (i64.MAX, u64.MAX] reinterprets when widened (u64.MAX → -1), so a `nat_to_int_coerce` obligation that the value is `<= i64.MAX` now fires at the return position — provably-in-range → Tier-1, provably-out-of-range (`@Nat.0 >= 2**63`) → loud E530, unbounded → honest Tier-3 (runtime-guarded), with an `@Int -> @Int` control that must not fire; the unguarded generic-`@Int`-field case also has its `E531` rationale read for WHAT IT SAYS — a value bounded on neither side, not the untranslatable-or-timeout conflation #1251 removed. The #813 follow-up adds the explicit `nat_to_int` built-in and heterogeneous `if`/`match` arms with a non-negative-literal alternative; #820 adds the heterogeneous-`@Int`-slot arm, closure argument, and closure return/capture obligations (each per-arm / per-site, with `@Int`-arm and `@Nat`-formal controls that must not fire) | | `test_int_widening_codegen.py` | 52 | 535 | #813 Stage 3 — runtime `@Nat -> @Int` widening-trap codegen: the codegen emits a guard at *exactly* the `@Nat -> @Int` coercion sites the verifier obligates (return, `let`, call argument, and — since #820 — array element, tuple construction/destructure, heterogeneous `if`/`match` arm, closure argument/return), so `vera run`/`vera compile` programs trap when a `@Nat` above i64.MAX would reinterpret to a negative `@Int` instead of silently returning the wrong value. The trap is a bare `unreachable` (shares `_emit_negative_i64_guard` with the #552 nat-bind guard), classified `kind="unreachable"` today (a dedicated widening trap kind is a follow-up) | | `test_int_widening_differential.py` | 26 | 320 | #813 verifier↔codegen behavioural differential (cross-component soundness rule): at every `@Nat -> @Int` coercion site the verifier's `nat_to_int_coerce` classification must AGREE with the runtime — a `tier3` (codegen-guarded) site MUST trap on a `@Nat` above i64.MAX (return / `let` / call-arg / constructor field / ADT sub-pattern / match-bind, and the #820 array-element / tuple-component / heterogeneous-arm / closure argument-return sites), while a `tier3_unguarded` (E531) site must NOT trap (the generic-instantiated `@Int`-field coercion codegen cannot guard). Runs BOTH sides on one corpus so the "runtime-guarded" claim is checked against the actual trap — catching a verifier deferral codegen never guards (unsound silent -1) or a spurious trap | @@ -183,7 +183,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_string_length_soundness.py` | 15 | 278 | #802 — string_length code-point vs UTF-8 byte soundness: a non-literal `string_length` defers to Tier 3 (the issue's `"é"` probe no longer proves `== 1` at Tier 1), a string-literal length is modeled at its exact UTF-8 byte count (`== 2` for `"é"`), and the boolean predicates `string_contains` / `string_starts_with` / `string_ends_with` stay Tier 1 (sound under UTF-8 self-synchronization), while a predicate over an astral (> U+2FFFF) or lone-surrogate literal defers to Tier 3 (z3.StringVal cannot model those code points) | | `test_errors.py` | 62 | 657 | Error code registry, diagnostic formatting, serialisation, SourceLocation, and error display sync — the canonical `E001` diagnostic must match each of its mirrors: `README.md`, `docs/index.html`, `spec/00-introduction.md`, `AGENTS.md`'s example `--json` block, and the hardcoded example in `scripts/build_site.py` that generates `docs/index.md` (#829; `AGENTS.md`'s ellipsis-truncated description/rationale are prefix-compared, its `error_code`/`spec_ref`/`fix` exactly) | | `test_eq_contract_874.py` | 13 | 430 | `eq`/`compare` ability ops in contract position: codegen canonicalization + verifier Tier-1 discharge/counterexample, where-fn contracts, compare Ordering-sort materialization, shadowing guard (#874) | -| `test_formatter.py` | 514 | 3,638 | Comment extraction, interior comment positioning, expression/declaration formatting, match arm block bodies, §1.8 rule 2 in value position (a `let`-bound `match`/`if` expands exactly as one in statement position, and a comment above an arm inside a statement's value stays on that arm), blank-line preservation (§1.8 rule 13 — gaps between statements, before a block result and around a comment, collapsed to one and never invented), idempotency, parenthesization, spec rules, ability declarations | +| `test_formatter.py` | 524 | 3,638 | Comment extraction, interior comment positioning, expression/declaration formatting, match arm block bodies, §1.8 rule 2 in value position (a `let`-bound `match`/`if` expands exactly as one in statement position, and a comment above an arm inside a statement's value stays on that arm), blank-line preservation (§1.8 rule 13 — gaps between statements, before a block result and around a comment, collapsed to one and never invented), idempotency, parenthesization, spec rules, ability declarations | | `test_cli.py` | 273 | 4,612 | CLI commands (check, verify, compile, run, serve, test, fmt, version, quiet), subprocess integration, JSON error paths (including the `verify --json` `obligations` array and its summary-reproducibility pin, #967, and the #1242 partition pin — the array is emitted unfiltered, a refuted obligation is counted by no summary field, and it still joins its E500 on the location key), runtime traps, arg validation, multi-file resolution, IO exit codes, --explain-slots (including the #1208 naming pins — an alias in type-argument position is tabled resolved, and a `forall` variable shadowing a module alias keeps the two parameter stacks apart — and the #1217 `where`-helper tables: the helper prints indented under its parent, appears in the JSON qualified as `parent.helper`, and inherits the enclosing `forall` variables so the shadowing holds inside it too), `builtins`/`effects`/`errors` introspection dispatch, and a USAGE-completeness guard (every dispatched `cmd_` handler has a help row) | | `test_introspect.py` | 39 | 221 | `vera builtins/effects/errors --json` registry introspection (#539): the `{schema, items}` envelope, count-equals-registry differential per registry, error-phase derivation, effect/ability `kind` tagging, the parameterised `Exn` effect, and best-effort `since` attribution with full-coverage guards | | `test_resolver.py` | 20 | 602 | Module resolution, path lookup, parse caching, circular import detection, the E011/E012/E013 diagnostic contract, internal-error isolation (a compiler bug is not masked as E013), and the transitive-closure return of `resolve_imports` (#890 — a diamond yields each reachable module once, direct imports tagged `direct`, the transitive one not) | @@ -201,7 +201,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_markdown.py` | 94 | 610 | Markdown parser: block/inline parsing, rendering, round-trips, edge cases | | `test_lsp.py` | 146 | 2470 | LSP transport + coordinate layer (#222 Phase C) and language features (#222 Phase D): parametrized code-point↔UTF-16 goldens incl. astral-plane fixtures and surrogate-pair snapping, Span (1-based, exclusive-end) and SourceLocation (0-based col) → LSP Range conversions, point→token-range widening, DocumentStore open/change/close + index invalidation, an in-process handler-drive test, and one stdio end-to-end round-trip against the real `vera lsp` subprocess (initialize → didOpen → shutdown → exit) pinning serverInfo + textDocumentSync capabilities; plus the Phase D feature suite — parse-error single-diagnostic path, type-error verification short-circuit, tier=3 in E520 diagnostic data, per-function tier Hint synthesis (and its suppression for functions with violated obligations), smallest-enclosing-span hover, De Bruijn slot goto (most-recent-parameter jump, out-of-range None, off-slot None, and the #1208 keying pins: a parameterised reference resolves, an alias-spelled parameter is reachable from a canonically-spelled reference, and a `forall` variable shadowing a module alias lands on the right parameter), and typed-hole completion (inside/after hole, away-from-hole None); plus the Phase E speculativeEdit suite — identical-text all-unchanged, breaking edit surfaces newly_undischarged (violated nat_sub) with canonical state untouched, strengthening edit surfaces newly_discharged, parse/type errors report ok:false, deleted functions report removed, proof_delta purity; plus the Phase F1 proposeEdit suite — the apply gate (clean and strengthening edits apply, breaking and non-compiling edits refuse), force overriding both gates with the delta still reported, wiring against a structural fake server (apply round-trip with exact full-document replacement range, refuse touches no canonical state, unopened-URI clamp sentinel), and full-document-range goldens (trailing-newline virtual line, UTF-16 end column); plus the Phase F2 strengthenContract suite — splice goldens (first-clause-only replacement with byte-identical remainder, ensures variant, unknown-fn None), the call-site audit pin (tightened precondition refused with newly_undischarged call_pre items, canonical state untouched), provable-ensures strengthening applies, and the three splice-target refusal paths (no analysis, unparseable document, unknown function); plus the Phase F3 addEffect suite — transitive-caller closure goldens (diamond in declaration order, leaf, unknown-fn None, recursion appears once), handler bounding (#725: a caller discharging the effect around its only call site drops out of the closure and is not rewritten, while a second unhandled path, a call in a handler clause, a handler for another effect, a handler naming a different instance of the same effect (`handle[State]` against a `State` propagation, which the checker does not discharge — end-to-end that caller must still be rewritten and the candidate must still apply, with the matching `State` propagation against the same fixture as the positive control that this handler key does prune something), and a bare `where`-helper call all keep it in, as does a refinement type argument at either depth (`Exn<{ @Int \| p }>` and `Exn>` render as their bare base type but discharge nothing of `Exn` — also pinned end-to-end), while an unparameterised `handle[IO]` does bound an `IO` propagation, a whitespace-spelled `State< Int >` request still bounds, and nesting bounds in either order (a matching handler inside a foreign one, a foreign one inside a matching one) — plus a key-level pin that `handle[Mod.IO]` keeps its module, and the effect-less query pinned handler-unaware; plus the two boundary pins the review added — a call in the handler's STATE INITIALISER keeps its edge, since the initialiser is evaluated in the enclosing scope before the handler is installed (pinned beside the E125 the checker raises there against a `pure` caller, with the identical call in the handler body clean as the contrast), and an alias-spelled handler does not bound a `State` propagation though the checker discharges it (`handle[State]` with `type MyAlias = Int` — the spelling comparison's under-prune, #1292, with the alias-spelled request as the control that does prune)), effect-row rewrite goldens (pure to singleton set, source-preserving append, already-present None, base-name identity blocking State next to State), diamond propagation applying one multi-site candidate with the bystander untouched, mixed append/replace rows with already-satisfied callers skipped, the fully-satisfied no-op shape, and the two refusal paths; plus the #728 instruction-contract suite — the LSP message carries description, rationale, and the Fix: paragraph (also pinning single E501 emission at the LSP surface), and a bare diagnostic maps to the description alone | | `test_browser.py` | 347 | 4,733 | Browser parity: Python/wasmtime vs Node.js/JS-runtime output equivalence across IO, State, contracts, Markdown, Regex, and the examples the browser target can execute (two explicit lists in the file, not the whole `examples/` directory — interactive stdin, file IO, `DB` and the non-standalone `modules` example are excluded with their reasons recorded); plus the #349 `runtime.mjs` coverage battery — per-value-type and per-key-type Map variants, per-element-type Set variants, cold `Decimal` branches (exact-zero sign, negative-shift division, `decRoundPlaces` special cases, non-finite storage), `readJson`/`json_stringify` across every Json ADT tag, the Regex/Json `Result.Err` arms, and nested-Markdown walks. Two operations carry a canonical form the specification states rather than merely agreeing across the hosts, so their batteries assert more than equality: `json_stringify` (spec §9.7.1) pins the expected string on every Json ADT tag and on the number-rendering boundaries, checks three-pass idempotence, checks that a non-finite `JNumber` fails on both hosts *and* prints nothing, pins the object key orders an ordinary JS object cannot carry (array-index keys, which ES enumeration hoists to the front in ascending numeric order, and a `__proto__` key, whose assignment writes a prototype instead of a field) on both a parsed and a program-built object, and checks the reference host's own number rendering differentially against a real `JSON.stringify` over doubles drawn from raw bit patterns; `md_render` (§9.7.3) pins the expected render, re-renders it to prove the fixed point, runs the round-trip property over a corpus carrying the container and multi-line shapes a flat corpus misses, and renders `MdBlock` values the test builds directly, since several renderer rules — a container's child separator, an empty container, a code span wider than one backtick — are unreachable through `md_parse`. `md_parse` itself is not yet at parity and its remaining divergence classes are a tracked bug ([#1301](https://github.com/aallan/vera/issues/1301)); the suite pins the shapes the two implementations agree on | -| `test_conformance.py` | 1095 | 144 | Parametrized conformance suite: parse, check, verify, run, format idempotency across 219 programs; a negative entry fails at the stage `expected_error_stage` names (`check`, the default, or `compile` — which also asserts the program type-checks cleanly first) | +| `test_conformance.py` | 1145 | 144 | Parametrized conformance suite: parse, check, verify, run, format idempotency across 219 programs; a negative entry fails at the stage `expected_error_stage` names (`check`, the default, or `compile` — which also asserts the program type-checks cleanly first) | | `test_prelude.py` | 29 | 585 | Prelude injection: Option/Result/array operation detection, combinator shadowing, type aliases, the reserved namespace every injected alias declaration lives in — checked against the checker's own E154 regex rather than a second spelling of the rule, since an alias the prelude injects outside it is one codegen resolves and the checker leaves opaque (#1184/#1221) — end-to-end compilation | | `test_checker_apply_fn.py` | 18 | 455 | #854 — `apply_fn` as a checker special form: zero-warning pins (API + CLI `--json` + closures.vera), E201 arity / E202 type / non-function-first-arg errors, E122/E125 effect-row enforcement for applied fn values, E151 redefinition rejection, variadic two-param application, prelude combinator regression pins | | `test_prelude_diagnostics.py` | 8 | 271 | #851 — prelude combinator skip-warnings: unreferenced-prelude E602/E604 suppression (zero-warning minimal compile, API + CLI `--json`), `` origin attribution for referenced-but-skipped combinators (text + `to_dict`), transitive reference scan, and user-fn warning locations pinned unchanged | @@ -222,11 +222,11 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_doc_builtin_shadowing.py` | 8 | 107 | `check_doc_builtin_shadowing.py` gate ([#819](https://github.com/aallan/vera/issues/819)): reject-set membership (opaque built-ins in, overridable combinators out), top-level + `where`-block `fn ` definitions flagged, non-built-in / overridable / prose-mention ignored, and the shipped docs are currently clean | | `test_runtime_traps.py` | 75 | 2,877 | Runtime trap categorisation (#516 Stage 1), out-of-bounds host-read bounds check (#1145), stdout/stderr-on-trap preservation (#522), `IO.print` live tee (#543), and trap source backtrace (#516 Stage 2): `_classify_trap` per-`kind` mapping (`divide_by_zero`/`out_of_bounds`/`stack_exhausted`/`unreachable`/`overflow`/`contract_violation`/`unknown`), `WasmTrapError` shape + `RuntimeError` substitutability, end-to-end `cmd_run` text + JSON envelopes including `trap_kind`, captured `stdout`, captured `stderr`, JSON-mode "no stderr leak" invariant, cross-stream code-order regression using merged `redirect_stdout`/`redirect_stderr`, the v0.0.123 tee suite (live streaming, write-count + order preservation, JSON-mode tee suppression, trap preservation invariant under tee, per-write flush count, default-execute silence), and the v0.0.124 source-mapping suite — `_resolve_trap_frames` unit tests covering user-fn / built-in / built-in-prefix / monomorphized base-name fallback / unknown-name / no-frames-attribute / leaf-first ordering preservation; end-to-end `cmd_run` text-mode + JSON-mode backtrace including the **leaf-first** ordering invariant; contract-violation backtrace in both text and JSON modes; direct `execute()` `WasmTrapError.frames` attachment; **suppression marker** for collapsed leading runtime-helper frames (mocked `vera.codegen.execute` with synthetic `is_builtin=True` leaf frames so the collapse logic is testable deterministically); source-map population for top-level fns + lifted closures (with span-value assertion against the closure literal's exact line range); and the no-builtin-leakage regression that pins built-in helpers (`alloc` / `gc_collect` / `contract_fail`) NOT being registered in `fn_source_map`; plus the v0.0.125 Stage 3 suite (`#547`) — text-mode `Fix:` block surfacing with position-ordering invariant (Fix appears after the source backtrace), text-mode block suppression for `contract_violation` (no empty header noise), JSON-mode `fix` field always-present (schema stability) including the empty-string case, `_TRAP_FIX_PARAGRAPHS` table-completeness assertion (every kind in the taxonomy has a Fix paragraph entry), and the column-wrap invariant (~76 chars max per line, two-space indent under the `Fix:` heading); plus the UTF-8 hardening suite **`TestHostPrintInvalidUtf8589`** (`#589` / `#592`) — after `#592` centralised the `errors="replace"` invariant into the single `vera.runtime.text.safe_utf8_decode` helper — reached only through a shared `_slice_and_decode` helper (`vera/runtime/heap.py`) that the three WASM-memory string readers (`_read_wasm_string` and `_read_string_export` there, and `vera/wasm/markdown.py::_read_string`) delegate to, with the `host_print` / `host_stderr` / `host_contract_fail` host imports and the String-return extractor in `execute()` routing through those readers rather than decoding inline: one helper unit test pinning the invariant once (invalid bytes → U+FFFD, valid + empty pass through), three wire-real end-to-end tests that drive the **production** readers (`_read_wasm_string` / markdown `_read_string` behind a synthetic-WAT `probe` host import; `_read_string_export` against a real exported memory, also covering its out-of-bounds → `None` pointer-fallback) over a region seeded with invalid UTF-8 — so a strict-decode regression surfaces as a `UnicodeDecodeError` escaping wasmtime's trampoline, and the host imports / extractor are transitively covered — and one synthetic-WAT end-to-end test that imports `vera.print` and calls it with raw invalid UTF-8 bytes to pin the wasmtime-trampoline fact independently (a Python `UnicodeDecodeError` inside a host import escapes as a "python exception" cause iff the host decode is strict); the six pre-`#592` structural source-grep assertions were retired by the centralisation; plus the Ctrl-C-during-host-import suite **`TestHostSleepKeyboardInterrupt`** ([#595](https://github.com/aallan/vera/issues/595) / [#599](https://github.com/aallan/vera/issues/599)) — after the v0.0.160 relocation to a single `except KeyboardInterrupt` handler in `execute()` (enabled by `wasmtime>=45.0.0`'s `except BaseException` trampoline fix): one structural assertion that the four per-host-import `raise _VeraExit(130)` guards are gone and the centralized handler maps to `exit_code=130`, plus four end-to-end tests that compile real Vera programs calling `IO.sleep(...)`, `IO.read_char(())`, a mocked fused `await` ([#841](https://github.com/aallan/vera/issues/841) — `Future.result()` patched to interrupt), and a live in-flight fused `await` (no mocking — `_thread.interrupt_main()` fired only once the server confirms the request arrived, handler then released so the executor teardown has a real worker to wait out; post-[#848](https://github.com/aallan/vera/issues/848) the progress print precedes the `async(...)`, so program order makes its stdout assertion deterministic), raise `KeyboardInterrupt` from inside the blocking call, and assert the program exits with `ExecuteResult.exit_code == 130` (pre-interrupt stdout preserved) instead of a raw Python traceback escaping wasmtime's trampoline | | `test_serve.py` | 8 | 189 | #305 `vera serve` driver end-to-end: GET/POST echo round-trips (method/path/headers/body cross the host↔guest boundary via `build_request_adt` / `decode_response_adt`), handler status propagation, runtime contract violation → 500 with `trap_kind` JSON, `State` isolation across requests (instance-per-request pinned), and clean `make_server` validation errors (missing / wrong-signature `handle`), and an eager-GC round-trip pinning the Request builder's shadow-rooting; all on ephemeral ports | -| `test_wasi_target.py` | 267 | 2,142 | #237 WASI Preview 2 target (spec chapter 13): component emission validated live against the real wasmtime host — parse (`Component(engine, wat)`), instantiate (`Linker.add_wasip2()` + `WasiConfig`), and execute (stdout/stderr capture, env, argv incl. a 500-arg GC-pressure stress and a >64 KiB arena-cap trap, preopen file round-trips + errno mapping, stdin incl. UTF-8 multibyte, clocks, random bounds, exit, contract-violation text on WASI stderr, overflow); the family gate (clean diagnostic naming unsupported families, never a silent fallback); the core-emission pin (default `--target wasm` WAT untouched); `cmd_compile`/`cmd_run --target wasi-p2` CLI integration (binary component artifact, `--wat` component text, JSON envelopes, trap-kind classification through the component boundary, exit-code 0/1 degradation, `--fn` rejection); the `execute_wasi_p2` host runner (env passthrough, argv, stderr capture, String-main `wasi:cli/run` fallback); the **dual-target conformance differential** (all 166 run-level conformance programs driven under both targets, byte-identical stdout/stderr required — 116 are dual-tested and 50 skip *loudly* rather than passing silently: 43 whose compiled WAT imports a host family outside `IO`/`Random` (`state`, `map`, `json`, `set`, `decimal`, `html`, `md`, `regex`, `db`), 6 with no public zero-argument `main`, and 1 calling a nondeterministic op. The excluded set is defined by those three properties rather than by a filename list, so it stays accurate as programs are added); a stock-`wasmtime`-CLI smoke test (skips when the CLI is not installed); and the Stage-D **server world** (`world="server"`): incoming-handler emission pins (adapter lift, 32-slot dispatch table, @0.2.0 version pin, no `wasi:cli/run`), #305 handler validation + server family-gate diagnostics (rejected IO ops, non-String map instantiations, unsupported families), the cli-world pin (default emission carries no server machinery), Request/Response layout tripwires, and a stock-`wasmtime serve` smoke battery (host-vs-served differential over a method/path/header/body matrix incl. duplicate-header later-wins, in-guest map-op order parity, `IO.print` console routing, trap→500 with symbolized backtrace + violation text, graceful 500s for forbidden headers and out-of-range status, a 1 MiB GC-stress echo, and an eager-GC shadow-push mutation validation; skips when the CLI is not installed) | +| `test_wasi_target.py` | 269 | 2,142 | #237 WASI Preview 2 target (spec chapter 13): component emission validated live against the real wasmtime host — parse (`Component(engine, wat)`), instantiate (`Linker.add_wasip2()` + `WasiConfig`), and execute (stdout/stderr capture, env, argv incl. a 500-arg GC-pressure stress and a >64 KiB arena-cap trap, preopen file round-trips + errno mapping, stdin incl. UTF-8 multibyte, clocks, random bounds, exit, contract-violation text on WASI stderr, overflow); the family gate (clean diagnostic naming unsupported families, never a silent fallback); the core-emission pin (default `--target wasm` WAT untouched); `cmd_compile`/`cmd_run --target wasi-p2` CLI integration (binary component artifact, `--wat` component text, JSON envelopes, trap-kind classification through the component boundary, exit-code 0/1 degradation, `--fn` rejection); the `execute_wasi_p2` host runner (env passthrough, argv, stderr capture, String-main `wasi:cli/run` fallback); the **dual-target conformance differential** (all 166 run-level conformance programs driven under both targets, byte-identical stdout/stderr required — 116 are dual-tested and 50 skip *loudly* rather than passing silently: 43 whose compiled WAT imports a host family outside `IO`/`Random` (`state`, `map`, `json`, `set`, `decimal`, `html`, `md`, `regex`, `db`), 6 with no public zero-argument `main`, and 1 calling a nondeterministic op. The excluded set is defined by those three properties rather than by a filename list, so it stays accurate as programs are added); a stock-`wasmtime`-CLI smoke test (skips when the CLI is not installed); and the Stage-D **server world** (`world="server"`): incoming-handler emission pins (adapter lift, 32-slot dispatch table, @0.2.0 version pin, no `wasi:cli/run`), #305 handler validation + server family-gate diagnostics (rejected IO ops, non-String map instantiations, unsupported families), the cli-world pin (default emission carries no server machinery), Request/Response layout tripwires, and a stock-`wasmtime serve` smoke battery (host-vs-served differential over a method/path/header/body matrix incl. duplicate-header later-wins, in-guest map-op order parity, `IO.print` console routing, trap→500 with symbolized backtrace + violation text, graceful 500s for forbidden headers and out-of-range status, a 1 MiB GC-stress echo, and an eager-GC shadow-push mutation validation; skips when the CLI is not installed) | ## Conformance Suite -The conformance suite is a collection of 226 small, focused programs in `tests/conformance/` that systematically validate every language feature against the spec. Most programs are self-contained; the module-focused Chapter 8 cases use `import` statements where needed, and `ch07_cross_module_contracts.vera` still depends on `ch07_cross_module_contracts_lib.vera`. Each program tests one feature or a small group of related features. +The conformance suite is a collection of 229 small, focused programs in `tests/conformance/` that systematically validate every language feature against the spec. Most programs are self-contained; the module-focused Chapter 8 cases use `import` statements where needed, and `ch07_cross_module_contracts.vera` still depends on `ch07_cross_module_contracts_lib.vera`. Each program tests one feature or a small group of related features. Simon Willison [argues](https://simonwillison.net/tags/conformance-suites/) that conformance suites are a "huge unlock" for language projects — they transform development from trust-based to verification-based. The conformance suite serves as the definitive specification artifact that any implementation (or agent) can validate against. @@ -251,15 +251,15 @@ Each conformance program declares the deepest pipeline stage it must pass: | Level | What it validates | Count | |-------|-------------------|------:| | `parse` | Source text is syntactically valid | 0 | -| `check` | Parses and type-checks cleanly | 40 | -| `verify` | Type-checks and all contracts verified by Z3 | 15 | -| `run` | Compiles to WASM and executes correctly | 164 | +| `check` | Parses and type-checks cleanly | 41 | +| `verify` | Type-checks and all contracts verified by Z3 | 20 | +| `run` | Compiles to WASM and executes correctly | 168 | -Almost all programs are at the `run` level — they compile and execute, producing correct results. Forty programs (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch03_typed_holes`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_cross_module_contracts_lib`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch07_bare_effect_op_rejected`, `ch08_circular_import`, `ch08_cross_module_generic_lib`, `ch08_module_generic_diamond_base`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_transitive_module_import_base`, `ch08_visibility_private`, `ch08_xmod_widen_lib`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_eq_non_derivable_rejected`, `ch09_http`, `ch09_inference`, `ch09_ord_adt_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) are at the `check` level. Thirty-two of them — `ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch07_bare_effect_op_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, and `ch02_alias_cycle_rejected` — are **negative tests** that assert a specific diagnostic (E206, E135, E183, E201, E127, E153, E153, E153, E130, E130, E174, E182, E217, E011, E154, E154, E154, E154, E154, E154, E150, E152, E151, E242, E243, E207, E208, E208, E209, E128, E336, and E132 respectively) via the manifest's `expected_error` field; `ch09_http` and `ch09_inference` are environment-gated (network / API key). Twenty programs (`ch03_slot_let_chains`, `ch03_slot_noncommutative`, `ch04_nested_option_ctor`, `ch04_primitive_obligations`, `ch05_apply_fn_typing`, `ch06_adt_sort_disambiguation`, `ch07_cross_module_contracts`, `ch07_invisible_import_op_name_lib`, `ch07_io_read_char`, `ch07_io_sleep`, `ch07_random_effect`, `ch08_state_alias_module_table_lib`, `ch08_module_generic_diamond_mid1`, `ch08_module_generic_diamond_mid2`, `ch08_state_alias_per_module_lib`, `ch08_transitive_module_import_mid`, `ch09_http_server`, `ch09_invisible_import_ability_op_lib`, `ch09_math_builtins`, `ch09_nested_helper_family_op_name_lib`) are at the `verify` level, using Z3-provable contracts — a library module is pinned at the deepest level it reaches, so the two per-module alias-table libraries are verified rather than only checked. +Almost all programs are at the `run` level — they compile and execute, producing correct results. Forty-one programs (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch03_typed_holes`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_cross_module_contracts_lib`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch07_bare_effect_op_rejected`, `ch08_circular_import`, `ch08_cross_module_generic_lib`, `ch08_module_generic_diamond_base`, `ch08_module_prelude_adt_contention_rejected`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_transitive_module_import_base`, `ch08_visibility_private`, `ch08_xmod_widen_lib`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_eq_non_derivable_rejected`, `ch09_http`, `ch09_inference`, `ch09_ord_adt_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) are at the `check` level. Thirty-two of them — `ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch07_bare_effect_op_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, and `ch02_alias_cycle_rejected` — are **negative tests** that assert a specific diagnostic (E206, E135, E183, E201, E127, E153, E153, E153, E130, E130, E174, E182, E217, E011, E154, E154, E154, E154, E154, E154, E150, E152, E151, E242, E243, E207, E208, E208, E209, E128, E336, and E132 respectively) via the manifest's `expected_error` field. One more — `ch08_module_prelude_adt_contention_rejected` — is a negative at the `compile` stage rather than at `check`: it carries `expected_error_stage: "compile"` beside `expected_error: E621`, so the harness asserts it type-checks CLEANLY and is then refused by `vera compile` with that code, which is the property a codegen-phase diagnostic exists for. `ch09_http` and `ch09_inference` are environment-gated (network / API key). Twenty programs (`ch03_slot_let_chains`, `ch03_slot_noncommutative`, `ch04_nested_option_ctor`, `ch04_primitive_obligations`, `ch05_apply_fn_typing`, `ch06_adt_sort_disambiguation`, `ch07_cross_module_contracts`, `ch07_invisible_import_op_name_lib`, `ch07_io_read_char`, `ch07_io_sleep`, `ch07_random_effect`, `ch08_state_alias_module_table_lib`, `ch08_module_generic_diamond_mid1`, `ch08_module_generic_diamond_mid2`, `ch08_state_alias_per_module_lib`, `ch08_transitive_module_import_mid`, `ch09_http_server`, `ch09_invisible_import_ability_op_lib`, `ch09_math_builtins`, `ch09_nested_helper_family_op_name_lib`) are at the `verify` level, using Z3-provable contracts — a library module is pinned at the deepest level it reaches, so the two per-module alias-table libraries are verified rather than only checked. ### Skipped tests -`pytest tests/ -v` skips 95 conformance-stage tests across the two categories below (the suite's remaining skips are platform- or tool-gated and documented beside the tests that declare them): +`pytest tests/ -v` skips 102 conformance-stage tests across the two categories below (the suite's remaining skips are platform- or tool-gated and documented beside the tests that declare them): **Level-limited skips** — the conformance framework only runs tests up to the declared level; stages beyond that level are automatically skipped. These are expected and correct. @@ -273,6 +273,11 @@ Almost all programs are at the `run` level — they compile and execute, produci | `test_run[ch02_map_unit_value_rejected]` | `ch02_map_unit_value_rejected.vera` | `check` | `run` | `check`-level negative test: no `run` stage | | `test_run[ch03_slot_let_chains]` | `ch03_slot_let_chains.vera` | `verify` | `run` | `verify`-level programs don't get a `run` test | | `test_run[ch03_slot_noncommutative]` | `ch03_slot_noncommutative.vera` | `verify` | `run` | `verify`-level programs don't get a `run` test | +| `test_run[ch07_invisible_import_op_name_lib]` | `ch07_invisible_import_op_name_lib.vera` | `verify` | `run` | `verify`-level programs don't get a `run` test | +| `test_run[ch08_module_generic_diamond_mid1]` | `ch08_module_generic_diamond_mid1.vera` | `verify` | `run` | `verify`-level programs don't get a `run` test | +| `test_run[ch08_module_generic_diamond_mid2]` | `ch08_module_generic_diamond_mid2.vera` | `verify` | `run` | `verify`-level programs don't get a `run` test | +| `test_run[ch09_invisible_import_ability_op_lib]` | `ch09_invisible_import_ability_op_lib.vera` | `verify` | `run` | `verify`-level programs don't get a `run` test | +| `test_run[ch09_nested_helper_family_op_name_lib]` | `ch09_nested_helper_family_op_name_lib.vera` | `verify` | `run` | `verify`-level programs don't get a `run` test | | `test_verify[ch03_typed_holes]` | `ch03_typed_holes.vera` | `check` | `verify` | `check`-level program: verify stage not run | | `test_run[ch03_typed_holes]` | `ch03_typed_holes.vera` | `check` | `run` | `check`-level program: no standalone `main` | | `test_verify[ch04_let_unit_rejected]` | `ch04_let_unit_rejected.vera` | `check` | `verify` | `check`-level negative test (`expected_error: E183`): verify stage not run | @@ -329,6 +334,8 @@ Almost all programs are at the `run` level — they compile and execute, produci | `test_run[ch08_reserved_vera_prefix_constructor_rejected]` | `ch08_reserved_vera_prefix_constructor_rejected.vera` | `check` | `run` | `check`-level negative test: no `run` stage | | `test_verify[ch08_cross_module_generic_lib]` | `ch08_cross_module_generic_lib.vera` | `check` | `verify` | `check`-level library module: verify stage not run | | `test_run[ch08_cross_module_generic_lib]` | `ch08_cross_module_generic_lib.vera` | `check` | `run` | `check`-level library module: no standalone `main` | +| `test_verify[ch08_module_generic_diamond_base]` | `ch08_module_generic_diamond_base.vera` | `check` | `verify` | `check`-level library module: verify stage not run | +| `test_run[ch08_module_generic_diamond_base]` | `ch08_module_generic_diamond_base.vera` | `check` | `run` | `check`-level library module: no standalone `main` | | `test_run[ch08_state_alias_module_table_lib]` | `ch08_state_alias_module_table_lib.vera` | `verify` | `run` | `verify`-level programs don't get a `run` test | | `test_run[ch08_state_alias_per_module_lib]` | `ch08_state_alias_per_module_lib.vera` | `verify` | `run` | `verify`-level programs don't get a `run` test | | `test_verify[ch08_transitive_module_import_base]` | `ch08_transitive_module_import_base.vera` | `check` | `verify` | `check`-level library module: verify stage not run | @@ -376,7 +383,7 @@ tests/conformance/ ├── ch01_int_literals.vera # Chapter 1: Integer literals ├── ch01_float_literals.vera # Chapter 1: Float64 literals ├── ch01_string_escapes.vera # Chapter 1: String escape sequences -├── ... # 219 programs total, organized by spec chapter +├── ... # 229 programs total, organized by spec chapter ├── ch07_state_handler.vera # Chapter 7: State effect handler ├── ch07_exn_handler.vera # Chapter 7: Exn effect handler ├── ch09_numeric_builtins.vera # Chapter 9: Numeric built-in functions @@ -859,9 +866,9 @@ Twenty-eight scripts in `scripts/` validate cross-cutting concerns beyond unit t | Script | What it validates | |--------|-------------------| -| `check_conformance.py` | All 219 conformance entries hold at their declared level (parse/check/verify/run) — positives pass; the negatives fail at the stage their `expected_error_stage` names (`check` by default, or `compile`, which also asserts the program type-checks cleanly) with their `expected_error` E-code | +| `check_conformance.py` | All 229 conformance entries hold at their declared level (parse/check/verify/run) — positives pass; the negatives fail at the stage their `expected_error_stage` names (`check` by default, or `compile`, which also asserts the program type-checks cleanly) with their `expected_error` E-code | | `check_examples.py` | All 42 `.vera` examples pass `vera check` + `vera verify` | -| `check_corpus_canonical.py` | All 268 corpus programs (recursive over `examples/` + `tests/conformance/`) are in canonical form under `vera fmt` | +| `check_corpus_canonical.py` | All 278 corpus programs (recursive over `examples/` + `tests/conformance/`) are in canonical form under `vera fmt` | | `check_examples_readme.py` | Every `vera run` command in examples/README.md references an existing file and exported function | | `check_spec_examples.py` | 189 parseable code blocks from spec chapters: parse, type-check, and verify | | `check_readme_examples.py` | All Vera code blocks in README.md parse correctly | @@ -967,9 +974,9 @@ The repository configures 35 hooks across two stages: 33 run at the commit stage | `ruff check .` | Lint Python with ruff (default `F` + `E` rules) | | `mypy vera/` | Type-check compiler in strict mode | | `pytest tests/ -q` | Run full test suite | -| `check_conformance.py` | All 219 conformance entries hold at their declared level — positives pass; negatives fail at the stage their `expected_error_stage` names (`check` or `compile`) with their `expected_error` E-code | +| `check_conformance.py` | All 229 conformance entries hold at their declared level — positives pass; negatives fail at the stage their `expected_error_stage` names (`check` or `compile`) with their `expected_error` E-code | | `check_examples.py` | All 42 examples pass `vera check` + `vera verify` | -| `check_corpus_canonical.py` | All 268 `examples/` + `tests/conformance/` programs (recursive) are in canonical form (`vera fmt`) | +| `check_corpus_canonical.py` | All 278 `examples/` + `tests/conformance/` programs (recursive) are in canonical form (`vera fmt`) | | `check_examples_readme.py` | `vera run` commands in `examples/README.md` reference existing files and exported functions | | `check_readme_examples.py` | README code blocks parse correctly | | `check_examples_doc.py` | EXAMPLES.md code blocks parse correctly | diff --git a/docs/SKILL.md b/docs/SKILL.md index 922de5b6..1c542797 100644 --- a/docs/SKILL.md +++ b/docs/SKILL.md @@ -2395,7 +2395,7 @@ public fn main(@Unit -> @Unit) ## Conformance Suite -The `tests/conformance/` directory contains 226 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. +The `tests/conformance/` directory contains 229 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. Each program is organized by spec chapter (`ch01_int_literals.vera`, `ch04_match_basic.vera`, `ch07_state_handler.vera`, etc.) and the `manifest.json` file maps features to programs. When you need to see how a specific construct works, check the conformance program before reading the spec. diff --git a/docs/index.html b/docs/index.html index f32db32c..8c1613e0 100644 --- a/docs/index.html +++ b/docs/index.html @@ -699,7 +699,7 @@

This page is also a machine-readable specificati Vera is under active development

- A complete compiler with 164 built-in functions, ten algebraic effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge), contract-driven testing via Z3, a language server with agent-facing proof deltas, and a 14-chapter specification. A 226-program conformance suite and 42 worked examples are validated against the spec on every pull request. All of it is developed openly on GitHub and released under the MIT licence. + A complete compiler with 164 built-in functions, ten algebraic effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge), contract-driven testing via Z3, a language server with agent-facing proof deltas, and a 14-chapter specification. A 229-program conformance suite and 42 worked examples are validated against the spec on every pull request. All of it is developed openly on GitHub and released under the MIT licence.

diff --git a/docs/index.md b/docs/index.md index 103fefa5..689b1afc 100644 --- a/docs/index.md +++ b/docs/index.md @@ -281,7 +281,7 @@ For other models: point them at [`SKILL.md`](https://veralang.dev/SKILL.md) via ## Status -Vera is under [active development](https://raw.githubusercontent.com/aallan/vera/main/ROADMAP.md). A complete compiler with 164 built-in functions, ten algebraic effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge), contract-driven testing via [Z3](https://www.microsoft.com/en-us/research/project/z3-3/), and a 14-chapter specification. A 226-program conformance suite and 42 worked examples are validated against the spec on every pull request. All of it is developed openly on [GitHub](https://github.com/aallan/vera) and released under the MIT licence. +Vera is under [active development](https://raw.githubusercontent.com/aallan/vera/main/ROADMAP.md). A complete compiler with 164 built-in functions, ten algebraic effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge), contract-driven testing via [Z3](https://www.microsoft.com/en-us/research/project/z3-3/), and a 14-chapter specification. A 229-program conformance suite and 42 worked examples are validated against the spec on every pull request. All of it is developed openly on [GitHub](https://github.com/aallan/vera) and released under the MIT licence. ## Links diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 0e607c07..eede9fc7 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -2401,7 +2401,7 @@ public fn main(@Unit -> @Unit) ## Conformance Suite -The `tests/conformance/` directory contains 226 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. +The `tests/conformance/` directory contains 229 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. Each program is organized by spec chapter (`ch01_int_literals.vera`, `ch04_match_basic.vera`, `ch07_state_handler.vera`, etc.) and the `manifest.json` file maps features to programs. When you need to see how a specific construct works, check the conformance program before reading the spec. @@ -2480,7 +2480,7 @@ Read `SKILL.md` for the full language reference. It covers syntax, slot referenc ### Conformance programs as reference -The conformance suite in `tests/conformance/` contains 226 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-two negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) instead must *fail* `check` with the E-code in their `expected_error` field. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. +The conformance suite in `tests/conformance/` contains 229 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-three negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) instead must *fail* with the E-code in their `expected_error` field, at the stage their `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. ### Workflow @@ -2659,9 +2659,9 @@ Each stage is a module with a single public API function (`parse_file`, `transfo pytest tests/ -v # Run all tests (see TESTING.md) pytest tests/test_conformance.py -v # Conformance suite only mypy vera/ # Type-check the compiler -python scripts/check_conformance.py # All 226 conformance programs hold (positives pass; negatives fail with their E-code) +python scripts/check_conformance.py # All 229 conformance programs hold (positives pass; negatives fail with their E-code) python scripts/check_examples.py # All 42 examples must pass -python scripts/check_corpus_canonical.py # All 274 corpus programs in canonical form +python scripts/check_corpus_canonical.py # All 278 corpus programs in canonical form ``` Test helpers follow a pattern: `_check_ok(source)` / `_check_err(source, match)` / `_verify_ok(source)` / `_verify_err(source, match)`. See existing tests for examples. @@ -2670,7 +2670,7 @@ When implementing a new language feature, write the conformance program *first* ### Invariants -- All 226 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) must *fail* `check` with their `expected_error` E-code +- All 229 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first - All 42 examples in `examples/` must pass `vera check` and `vera verify` - `mypy vera/` must be clean - `pytest tests/ -v` must pass @@ -3215,7 +3215,7 @@ None of this is Vera-specific, but it validates the design choices. The thesis i This is a real concern. LLMs are trained on trillions of tokens of Python, TypeScript, and JavaScript. A MojoBench study (NAACL 2025) found that even fine-tuned models achieved only 30–35% improvement over base models on Mojo code generation, illustrating the cold-start problem for new languages. -Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (226 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification. +Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (229 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification. ## How does Vera compare to Dafny / Lean / Koka / F*? @@ -3258,7 +3258,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 10,991 tests, including a 226-program conformance suite +- 11,072 tests, including a 229-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/docs/llms.txt b/docs/llms.txt index 0e9cad56..f889f329 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -56,4 +56,4 @@ Current version: 0.1.11. The reference compiler is written in Python. Install th - [TESTING.md](https://raw.githubusercontent.com/aallan/vera/main/TESTING.md): Test suite architecture, coverage data, and test conventions. - [KNOWN_ISSUES.md](https://raw.githubusercontent.com/aallan/vera/main/KNOWN_ISSUES.md): Known bugs and limitations. - [CONTRIBUTING.md](https://raw.githubusercontent.com/aallan/vera/main/CONTRIBUTING.md): Contribution guidelines. -- [Conformance Suite](https://github.com/aallan/vera/tree/main/tests/conformance): 226 programs validating every language feature against the spec. +- [Conformance Suite](https://github.com/aallan/vera/tree/main/tests/conformance): 229 programs validating every language feature against the spec. diff --git a/tests/test_prelude_adt_namespace_1277.py b/tests/test_prelude_adt_namespace_1277.py index 10ea5803..5143ea0c 100644 --- a/tests/test_prelude_adt_namespace_1277.py +++ b/tests/test_prelude_adt_namespace_1277.py @@ -43,7 +43,7 @@ import pytest -from vera import naming +from vera import ast, naming from vera.checker.core import TypeChecker from vera.codegen.core import CodeGenerator from vera.errors import ERROR_CODES @@ -582,11 +582,18 @@ def test_restating_the_prelude_shape_is_not_a_contention( } -def _two_module_cell( +def _two_module_gen( tmp_path: Path, decl_a: str, decl_b: str, *, a_first: bool, uses_ordering: bool = True, -) -> tuple[list[str], list[str]]: - """(codes, exports) for two modules that each declare one name.""" +) -> CodeGenerator: + """Compile two modules that each declare one name; hand back the generator. + + *a_first* decides which module the ENTRY imports first, so the two + parametrizations really are two different programs. That is the whole + point of the cases below — the defect they pin was order-dependent, and + a fixture with a fixed import order would run the same program twice + and claim to have covered both. + """ def lib(fn: str, decl: str) -> str: return f"""{decl} @@ -624,11 +631,22 @@ def lib(fn: str, decl: str) -> str: {fn1}(1) + {fn2}(2) }} """ - gen = _compile(tmp_path, { + return _compile(tmp_path, { "alib.vera": lib("afn", decl_a), "blib.vera": lib("bfn", decl_b), "main.vera": entry, }) + + +def _two_module_cell( + tmp_path: Path, decl_a: str, decl_b: str, *, + a_first: bool, uses_ordering: bool = True, +) -> tuple[list[str], list[str]]: + """(codes, exports) for two modules that each declare one name.""" + gen = _two_module_gen( + tmp_path, decl_a, decl_b, + a_first=a_first, uses_ordering=uses_ordering, + ) result = gen._result # type: ignore[attr-defined] return ( sorted({d.error_code for d in result.diagnostics if d.error_code}), @@ -668,48 +686,32 @@ def test_a_second_module_declaration_is_examined_too( def test_two_differing_module_declarations_are_both_reported( tmp_path: Path, a_first: bool, ) -> None: - """Each differing declaration is its own problem, and gets its own report.""" - gen = _compile(tmp_path, { - "alib.vera": f"""{_ORD_DIFFER} - -public fn afn(@Int -> @Int) - requires(true) - ensures(true) - effects(pure) -{{ - {_DECL_BODY[_ORD_DIFFER]} -}} -""", - "blib.vera": f"""{_ORD_DIFFER2} - -public fn bfn(@Int -> @Int) - requires(true) - ensures(true) - effects(pure) -{{ - {_DECL_BODY[_ORD_DIFFER2]} -}} -""", - "main.vera": """import alib(afn); -import blib(bfn); - -public fn main(@Unit -> @Int) - requires(true) - ensures(true) - effects(pure) -{ - afn(1) + bfn(2) -} -""", - }) + """Each differing declaration is its own problem, and gets its own report. + + Both parametrizations are genuinely different programs — the entry's + import order follows *a_first* — because the rule under test is that + the rail examines every declarer rather than whichever came first. + The report ORDER is asserted against the import order too: it is the + one observable that distinguishes the two ids, so without it a fixture + that lost its order-sensitivity again would still pass both. + """ + gen = _two_module_gen( + tmp_path, _ORD_DIFFER, _ORD_DIFFER2, + a_first=a_first, uses_ordering=False, + ) result = gen._result # type: ignore[attr-defined] e621 = [d for d in result.diagnostics if d.error_code == "E621"] assert len(e621) == 2, [ (d.error_code, d.location.file) for d in result.diagnostics] - assert {Path(d.location.file).name for d in e621} == { - "alib.vera", "blib.vera"}, [d.location.file for d in e621] + reported = [Path(d.location.file).name for d in e621] + assert set(reported) == {"alib.vera", "blib.vera"}, reported + expected = ["alib.vera", "blib.vera"] if a_first else [ + "blib.vera", "alib.vera"] + assert reported == expected, ( + f"reported {reported}; the rail walks the declarers in resolution " + f"order, which follows the entry's import order" + ) assert sorted(result.exports) == [] - del a_first # the assertion is order-independent by construction @pytest.mark.parametrize("a_first", [True, False], ids=["alib1st", "blib1st"]) @@ -792,7 +794,7 @@ def test_prelude_adt_names_are_exactly_what_the_prelude_injects( inject_prelude(program) injected = frozenset( tld.decl.name for tld in program.declarations - if type(tld.decl).__name__ == "DataDecl" + if isinstance(tld.decl, ast.DataDecl) ) assert injected == prelude_adt_names(), ( sorted(injected ^ prelude_adt_names())) diff --git a/tests/test_prelude_decl_stamp_1287.py b/tests/test_prelude_decl_stamp_1287.py index 1a3f24a6..d2da6b6c 100644 --- a/tests/test_prelude_decl_stamp_1287.py +++ b/tests/test_prelude_decl_stamp_1287.py @@ -89,7 +89,15 @@ def _compiled(tmp_path: Path, main_src: str) -> CodeGenerator: - """Compile *main_src* against ``mlib`` and hand back the generator.""" + """Compile *main_src* against ``mlib`` and hand back the generator. + + The compile RESULT is asserted clean before the generator is handed + back. Every claim below is about the bookkeeping of a program the + compiler accepts, and discarding the result would let these cases go + on passing over a program codegen had started refusing — a shadowing + `type Option = Int` is legal under §8.4.1 today, and the whole point + of the fixture is that it stays that way. + """ tmp_path.mkdir(parents=True, exist_ok=True) (tmp_path / "mlib.vera").write_text(_MLIB, encoding="utf-8") main_path = tmp_path / "main.vera" @@ -100,7 +108,11 @@ def _compiled(tmp_path: Path, main_src: str) -> CodeGenerator: source=main_path.read_text(encoding="utf-8"), file=str(main_path), ) gen._resolved_modules = mods - gen.compile_program(program) + result = gen.compile_program(program) + errors = [d for d in result.diagnostics if d.severity == "error"] + assert errors == [], [ + (d.error_code, d.description) for d in errors] + assert result.exports == ["main"], result.exports return gen diff --git a/vera/README.md b/vera/README.md index fe7e1da9..74221183 100644 --- a/vera/README.md +++ b/vera/README.md @@ -130,7 +130,7 @@ execute(compile_result, ...) # → run WASM via wasmtime | `codegen/` | 18,981 | Compile | Codegen orchestrator (mixin package) | `compile()`, `execute()` | | ` api.py` | 1,402 | | Public API, dataclasses, `compile()`/`execute()` orchestration, core IO host bindings (#421) | | | ` memory.py` | 105 | | Compile-time ADT layout helpers (`ConstructorLayout`, alignment) (#421) | | -| ` core.py` | 2,952 | | CodeGenerator class, orchestration, ability op rewriting (Pass 1.6), skip propagation to callers (#1100) | | +| ` core.py` | 3,348 | | CodeGenerator class, orchestration, ability op rewriting (Pass 1.6), skip propagation to callers (#1100) | | | ` modules.py` | 1,422 | | Cross-module registration + call detection (C7e), per-module alias + source scopes (#1111/#1186) — `_module_alias_scope` swaps the alias maps *and* the `AliasEnv` every codegen rendering goes through as one pair (#1208) | | | ` registration.py` | 499 | | Pass 1 forward declarations, ADT layout | | | ` monomorphize.py` | 1,534 | | Generic instantiation, type inference, ability constraint checking (Pass 1.5) | | @@ -752,7 +752,7 @@ The `ERROR_CODES` dict in `errors.py` maps every code to a short description (15 ## Test Suite -Testing spans a **pytest suite** of 10,991 tests across 166 files — compiler-internals unit tests plus a **conformance suite** (226 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. +Testing spans a **pytest suite** of 11,072 tests across 168 files — compiler-internals unit tests plus a **conformance suite** (229 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. See **[TESTING.md](../TESTING.md)** for the comprehensive testing reference -- test file table, conformance suite details, compiler code coverage, language feature coverage, helper conventions, validation scripts, CI pipeline, and guidelines for adding tests. diff --git a/vera/prelude.py b/vera/prelude.py index c1a23809..b9dd71bd 100644 --- a/vera/prelude.py +++ b/vera/prelude.py @@ -18,6 +18,8 @@ import functools import re +from types import MappingProxyType +from typing import Mapping from vera import ast @@ -780,7 +782,7 @@ def _parse_source(source: str) -> ast.Program: # ===================================================================== @functools.lru_cache(maxsize=1) -def prelude_data_decls() -> dict[str, ast.DataDecl]: +def prelude_data_decls() -> Mapping[str, ast.DataDecl]: """The prelude's own ``data`` declarations, by name (#1277). Derived by PARSING the same source blocks :func:`inject_prelude` @@ -792,13 +794,19 @@ def prelude_data_decls() -> dict[str, ast.DataDecl]: ADT-membership floor) and codegen's Pass-1.2 contention rail, which compares a module's declaration of one of these names against the prelude's own with :func:`data_decl_shape`. + + READ-ONLY, because the cache hands every caller the same object: a + plain dict would let one consumer's mutation reach the other and + every later compile in the process, and the AST nodes inside it are + the ones the rail compares against. The proxy makes the shared + identity safe rather than merely undocumented. """ parsed = _parse_source("\n".join(_PRELUDE_DATA_SOURCES)) - return { + return MappingProxyType({ tld.decl.name: tld.decl for tld in parsed.declarations if isinstance(tld.decl, ast.DataDecl) - } + }) def prelude_adt_names() -> frozenset[str]: From 0cb726005a7f5a79d0913d93aec3f10f7df28551 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 12:50:37 +0100 Subject: [PATCH 29/69] Close CodeRabbit wave 2: the shape key reads spellings, and four smaller ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Major first, and it was wrong in both directions. `data_decl_shape` compared the two declarations' raw field SPELLINGS, so a module that restates the prelude's type through its own alias — `type Payload = String;` with a `UrlParts` of five `Payload` fields — described exactly the prelude's layout and was refused E621 on a `vera check`-green program §8.4.1 permits. The reverse is live too and worse: `type Array = Int;` in a module makes its `JArray(Array)` an `Int` field while the spelling stays `Array`, so the raw key called the two layouts the same, the module's `Json` took the slot, the entry's `json_array_length` read it, and nothing was reported at all — `ok: true`, both functions exported. Each declaration's field types are now canonicalized through the alias maps of the namespace it was WRITTEN in: the module's own for the module's declaration (§8.4.1 makes an alias module-local, so no other namespace's may answer — the #1111/#1253 rule), and none at all for the prelude's, which is its own namespace. One side only, deliberately: resolving the prelude's spelling through a module's aliases collapses that second shape's two keys back together, which is why it is the mutation control rather than a symmetry to restore. A type parameter shadows an alias of its own name, so the declaration's parameters are withheld from the map before substitution. Four smaller ones. TESTING.md's dual-target row was stale on the union tree — re-measured from a live `TestDualTargetConformance -rs` run: 168 run-level programs driven, 118 dual-tested, 50 skipping loudly, and the 43/6/1 split below it re-measured unchanged. SKILL.md's E621 sentence now says that type parameters are matched by position and their names are free. The idempotence unit case gains the already-stamped branch directly — a main-file stamp followed by the prelude's, asserting the prelude block gets its own index while `_decl_order` keeps the main file's — so the branch the #1287 fix turns on is pinned without going through the `_SHADOWING` compile. And the module map's line counts are refreshed against `check_doc_counts.py`'s own row-to-file mapping: 39 rows had drifted, most of them from #1311 rather than from here, and leaving measured-wrong numbers in place is not better for being someone else's drift. Co-Authored-By: Claude --- CHANGELOG.md | 2 +- FAQ.md | 2 +- README.md | 2 +- ROADMAP.md | 2 +- SKILL.md | 2 +- TESTING.md | 8 +- docs/SKILL.md | 2 +- docs/llms-full.txt | 4 +- tests/test_prelude_adt_namespace_1277.py | 173 +++++++++++++++++++++++ tests/test_prelude_decl_stamp_1287.py | 13 ++ vera/README.md | 80 +++++------ vera/codegen/core.py | 15 +- vera/prelude.py | 42 +++++- 13 files changed, 289 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fd8d109..d144f1d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). All 258 pre-existing conformance and example programs emit byte-identical WAT, and all three gates now have conformance coverage: reverting any one of them turns the suite red. - **E608 no longer refuses two modules' provably distinct generics** ([#1281](https://github.com/aallan/vera/issues/1281)). The flat-namespace collision rail exists because Pass 2.5 emits every imported function under one WASM name — but a generic emits nothing under its bare name, and since [#1274](https://github.com/aallan/vera/issues/1274) its clones live in a namespace chosen per *owner*: `gen$Bool` for a generic that owns the importer's bare name, `mod$$gen$Bool` for one that does not. A diamond where `base` declares a public `forall fn gen` and `mid1` a private one occupies two different namespaces and was refused outright with `Function 'gen' is defined in both imported module 'mid1' and 'base'`, while `vera verify` returned rc=0 on the same program — a loud verify-vs-compile disagreement. The rail now reads the same ownership classification the clone namespace does, and fires only when the pair really can collide: when either declaration is not a top-level generic (a non-generic *is* emitted under the bare `$name`), when both own the bare name, or when some namespace can name both — a module importing two dependencies that each export `gen` resolves its own bare call to one of them, and neither spec §8.5 nor the checker says which: the checker's pick is a set-iteration artefact, and the identical program accepts on one run and is rejected on the next (measured 4/4 over eight runs, stable under a fixed `PYTHONHASHSEED`, and unchanged by the textual order of the two imports), so that shape keeps its refusal rather than compiling a body chosen by coin-flip ([#1304](https://github.com/aallan/vera/issues/1304)). The registration moved with the message, as defence in depth: a qualified-only generic no longer injects a bare `_fn_sigs` or `_fn_ret_type_exprs` entry at all. Those two registries are read *per name* by consumers the clone classification says nothing about — `MonoContext.fn_names`, the [#1207](https://github.com/aallan/vera/issues/1207) shadow guard, and the WASM call-rewrite's return-type lookup — where first-module-wins would make the answer depend on registration order. What actually closes that shape is the #1299 scope narrowing above, which reaches the same consultors through the call site: reverting both withholdings leaves every suite and all 224 conformance programs green. They are kept, and pinned structurally on the tables they act on with one cell each, because nothing but those four consumers' current internals stops any of them from picking a winner. -- **A module's data type no longer empties a prelude one out of every other namespace, and the shape that silently dropped functions is now an error** ([#1277](https://github.com/aallan/vera/issues/1277)). Codegen keeps ONE flat `_adt_layouts` map while the checker gives every namespace the prelude's data types from the start, and the two halves of that gap failed differently. **Membership**: `_adt_members_in_scope` recovered global infrastructure by SUBTRACTING what the namespaces declare from the registered layouts, which is sound only while "declared by a namespace" and "global infrastructure" are disjoint — and §8.4.1 makes them overlap on purpose, since the prelude's data types are ordinary public declarations a program may name and shadow. So one file's `data Json` removed `Json` from the member set of every OTHER namespace, including the entry program's, while the checker's `TypeEnv` carried it in all of them; measured as a straight disagreement, `Json` a data type in a module's namespace for the checker and not for codegen. The Pass-0.5 built-in snapshot unioned in as a floor could not protect the four demand-injected prelude ADTs, because it is taken before Pass 1.2 injects them — the same asymmetry [#1253](https://github.com/aallan/vera/issues/1253) fixed, one layer down. The floor is now stated positively rather than recovered by elimination: `vera.prelude.prelude_adt_names()` parses the prelude's own data blocks with the same parser `inject_prelude` uses, so a new prelude ADT joins the set by being written, and a differential holds the two against each other; the cached `prelude_data_decls()` behind it hands back a read-only mapping, since one cached object is shared by every caller in the process. Scoping the subtraction per namespace instead — the other direction the issue left open — is refuted by `tests/test_adt_membership_scope_1253.py`, which it re-opens: a sibling module's ADT would become infrastructure for every namespace but its own. **Contention**: where a MODULE declares one of the prelude's data type names with a DIFFERENT SHAPE and the prelude is also compiling its own, the two contend for the one layout slot and the module's wins. The prelude's ADT was then never registered, its own combinators hit `unknown constructor` (an `[E602]` inside ``), and every user function touching the type was dropped behind an `[E620]` cascade — all of it reported as WARNINGS, so a `vera check`-green program compiled with **exit 0** to a module with a function silently missing from its exports, and nothing named the declaration that caused it. That is now **E621**, an error located at the module's declaration in the module's own file, refused by the same Pass-1.9 severity gate `E608`/`E609`/`E610` use. It covers all eight of the prelude's data types, which required reading the DECLARATIONS rather than the registered layouts: the layout harvest skips a built-in name outright, because the throwaway registrar holds `Option`, `Result`, `Ordering` and `UrlParts` for every module whether it declares them or not, so a layout-keyed rail saw `data Json` and never `data Option`. The two halves differ only in when the prelude is present — the demand-injected four not until the entry program uses them, the always-injected four in every program — so a differently-shaped module `data Ordering` contends unconditionally, which upgrades that shape from an `[E602]`/`[E620]` cascade to one instruction. What decides contention is the two declarations' SHAPES: the same constructors, in the same order (the tag is the position), with the same field types, type parameters compared positionally. A module that restates the prelude's type shares the one layout — measured legal for all eight, and kept legal, which is what stops the rail from becoming the reservation §8.4.1 forbids. That is not a hypothetical: `examples/vera/collections.vera` declares `public data Option { None, Some(T) }` and `examples/modules.vera` imports it, so a rail that fired on the name alone refuses a shipped example — `vera compile` on `examples/modules.vera` returns E621 under that mutation, which `scripts/check_e602_clean.py` catches as a `COMPILE_ERROR` (measured; `check_examples.py` does not, running only `check` and `verify`). The rail's first form refused four of the synthetic restatements. Every declaring module is asked, not the first: a library that restates the prelude's `Ordering` otherwise answered for a sibling declaring a different one, so with the restating module imported first the sibling's contention went unseen — check-green, exit 0, the caller silently dropped — and the reverse import order caught it. An order-dependent rail is not a rail, and the battery now carries both orders. The module-versus-module pair for a name the prelude does NOT provide stays E609's, which a control pins. The acceptance battery is a parameterized test over all eight names in both shapes plus the restatement control, asserting that no cell reports `[E602]`/`[E620]` and that no zero-exit compile is missing a function, so the four-of-eight coverage the rail started with cannot return silently; `ch08_module_prelude_adt_contention_rejected` pins the same refusal at conformance level, paired with the positive that imports the same module and never names the type. The conformance manifest gained an `expected_error_stage` key for it (`"check"`, the default, or `"compile"`): a compile-stage negative asserts that the program type-checks CLEANLY and is then refused by `vera compile` with the declared code, which is the property a codegen-phase diagnostic exists for and which the check-only negative path could not express. Reserving the prelude's names is not the fix and is not done: §8.4.1 forbids it. +- **A module's data type no longer empties a prelude one out of every other namespace, and the shape that silently dropped functions is now an error** ([#1277](https://github.com/aallan/vera/issues/1277)). Codegen keeps ONE flat `_adt_layouts` map while the checker gives every namespace the prelude's data types from the start, and the two halves of that gap failed differently. **Membership**: `_adt_members_in_scope` recovered global infrastructure by SUBTRACTING what the namespaces declare from the registered layouts, which is sound only while "declared by a namespace" and "global infrastructure" are disjoint — and §8.4.1 makes them overlap on purpose, since the prelude's data types are ordinary public declarations a program may name and shadow. So one file's `data Json` removed `Json` from the member set of every OTHER namespace, including the entry program's, while the checker's `TypeEnv` carried it in all of them; measured as a straight disagreement, `Json` a data type in a module's namespace for the checker and not for codegen. The Pass-0.5 built-in snapshot unioned in as a floor could not protect the four demand-injected prelude ADTs, because it is taken before Pass 1.2 injects them — the same asymmetry [#1253](https://github.com/aallan/vera/issues/1253) fixed, one layer down. The floor is now stated positively rather than recovered by elimination: `vera.prelude.prelude_adt_names()` parses the prelude's own data blocks with the same parser `inject_prelude` uses, so a new prelude ADT joins the set by being written, and a differential holds the two against each other; the cached `prelude_data_decls()` behind it hands back a read-only mapping, since one cached object is shared by every caller in the process. Scoping the subtraction per namespace instead — the other direction the issue left open — is refuted by `tests/test_adt_membership_scope_1253.py`, which it re-opens: a sibling module's ADT would become infrastructure for every namespace but its own. **Contention**: where a MODULE declares one of the prelude's data type names with a DIFFERENT SHAPE and the prelude is also compiling its own, the two contend for the one layout slot and the module's wins. The prelude's ADT was then never registered, its own combinators hit `unknown constructor` (an `[E602]` inside ``), and every user function touching the type was dropped behind an `[E620]` cascade — all of it reported as WARNINGS, so a `vera check`-green program compiled with **exit 0** to a module with a function silently missing from its exports, and nothing named the declaration that caused it. That is now **E621**, an error located at the module's declaration in the module's own file, refused by the same Pass-1.9 severity gate `E608`/`E609`/`E610` use. It covers all eight of the prelude's data types, which required reading the DECLARATIONS rather than the registered layouts: the layout harvest skips a built-in name outright, because the throwaway registrar holds `Option`, `Result`, `Ordering` and `UrlParts` for every module whether it declares them or not, so a layout-keyed rail saw `data Json` and never `data Option`. The two halves differ only in when the prelude is present — the demand-injected four not until the entry program uses them, the always-injected four in every program — so a differently-shaped module `data Ordering` contends unconditionally, which upgrades that shape from an `[E602]`/`[E620]` cascade to one instruction. What decides contention is the two declarations' SHAPES: the same constructors, in the same order (the tag is the position), with the same field types, type parameters compared positionally — and each declaration's field types are resolved through the alias maps of the namespace it was WRITTEN in, the module's own for the module's declaration and none at all for the prelude's. Both halves of that are load-bearing. A module that restates the prelude's type through its own alias (`type Payload = String;`) is still a restatement, and comparing raw spellings refused it. Resolving the prelude's spelling through a module's aliases would go wrong the other way: `type Array = Int;` in a module makes its `JArray(Array)` an `Int` field, and with both sides resolved the two keys collapse — which is how that program compiles today, with the module's layout in the slot, the entry's `json_array_length` reading it, and no diagnostic at all. A module that restates the prelude's type shares the one layout — measured legal for all eight, and kept legal, which is what stops the rail from becoming the reservation §8.4.1 forbids. That is not a hypothetical: `examples/vera/collections.vera` declares `public data Option { None, Some(T) }` and `examples/modules.vera` imports it, so a rail that fired on the name alone refuses a shipped example — `vera compile` on `examples/modules.vera` returns E621 under that mutation, which `scripts/check_e602_clean.py` catches as a `COMPILE_ERROR` (measured; `check_examples.py` does not, running only `check` and `verify`). The rail's first form refused four of the synthetic restatements. Every declaring module is asked, not the first: a library that restates the prelude's `Ordering` otherwise answered for a sibling declaring a different one, so with the restating module imported first the sibling's contention went unseen — check-green, exit 0, the caller silently dropped — and the reverse import order caught it. An order-dependent rail is not a rail, and the battery now carries both orders. The module-versus-module pair for a name the prelude does NOT provide stays E609's, which a control pins. The acceptance battery is a parameterized test over all eight names in both shapes plus the restatement control, asserting that no cell reports `[E602]`/`[E620]` and that no zero-exit compile is missing a function, so the four-of-eight coverage the rail started with cannot return silently; `ch08_module_prelude_adt_contention_rejected` pins the same refusal at conformance level, paired with the positive that imports the same module and never names the type. The conformance manifest gained an `expected_error_stage` key for it (`"check"`, the default, or `"compile"`): a compile-stage negative asserts that the program type-checks CLEANLY and is then refused by `vera compile` with the declared code, which is the property a codegen-phase diagnostic exists for and which the check-only negative path could not express. Reserving the prelude's names is not the fix and is not done: §8.4.1 forbids it. - **The prelude's declaration-index block no longer depends on what the main file declares** ([#1287](https://github.com/aallan/vera/issues/1287)). `_stamp_decl_order` guarded its PRELUDE write on `_decl_order`, the ACTIVE (main-file) namespace — but `_prelude_decl_order` is not a namespace: `_module_alias_scope` builds every module's index space as `{**prelude, **module_own}`, so it is the base layer under all of them and its contents are a fact about what `inject_prelude` laid down. A main-file `type Option = Int` is accepted (§8.4.1 again) and, being an alias rather than a `data`, does not suppress the prelude's own `data Option`, so the guard fired on the prelude stamp: `Option` was left out of the block entirely, and every later prelude declaration shifted one place earlier because the skipped stamp never advanced the counter. Inside a module namespace the prelude's `Option` then reached `AliasEnv.data_types` at `_BUILTIN_DECL_INDEX` — below `_PRELUDE_DECL_BASE`, so ordered ahead of every other prelude declaration rather than among them — which is exactly the cross-namespace leak `_decl_order` and `_module_decl_order` were split apart to prevent. Latent at emission: that map changes a rendering only for `Decimal` and the single `REMOVED_ALIASES` entry `Float`, and no prelude ADT is either, so no WAT moves; the defect is the wrong value reaching the consumer. The prelude write is now unconditional and the ACTIVE space still takes the main file's stamp, so the shadow keeps winning its own namespace. Pinned as an invariance — the same program with and without the shadowing alias must stamp an identical prelude block — with the main-namespace control that a fix stamping `_decl_order` unconditionally would fail. diff --git a/FAQ.md b/FAQ.md index bca783d2..cdb8a797 100644 --- a/FAQ.md +++ b/FAQ.md @@ -279,7 +279,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 11,072 tests, including a 229-program conformance suite +- 11,075 tests, including a 229-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/README.md b/README.md index 9a319db3..ada61e88 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ cp /path/to/vera/SKILL.md ~/.claude/skills/vera-language/SKILL.md ## Project status -Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 11,072 tests, 95% Python code coverage, 229 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. +Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 11,075 tests, 95% Python code coverage, 229 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. The reference compiler — parser, AST, type checker, contract verifier (Z3), WASM code generator, module system, browser runtime, and runtime contract insertion — is working. The language specification is in draft across [14 chapters](spec/). diff --git a/ROADMAP.md b/ROADMAP.md index b8379562..c3c90e42 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,7 +12,7 @@ Ordering derives from the design principles ([DESIGN.md](DESIGN.md)): verificati ## Where we are -11,072 tests, 229 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. +11,075 tests, 229 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. ## Stage 19 — The verification completeness sprint diff --git a/SKILL.md b/SKILL.md index 1cb4fc11..a85ecd10 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1953,7 +1953,7 @@ Cross-module compilation uses a flattening strategy: imported function bodies ar If two imported modules define a function, data type, or constructor with the same name, the compiler reports an error (E608/E609/E610) listing both conflicting modules. Rename one of the conflicting declarations in the source module to resolve the collision. Local definitions shadow imported names without error. -A module's data type may also collide with one the prelude provides (`Option`, `Result`, `Ordering`, `UrlParts`, `Json`, `HtmlNode`, `Request`, `Response`). One name carries one layout in the compiled program, so the two contend when their shapes differ — different constructors, a different constructor order, or different field types. The compiler then reports **E621** at the module's declaration; rename it there, or give it the prelude's shape. A module declaring `data Json` is legal on its own, because the prelude injects `Json` only when the entry program uses it; `Option`, `Result`, `Ordering` and `UrlParts` are in every program, so a differently-shaped module declaration of one of those always contends. Declaring the type in the **entry** file instead suppresses the prelude's own, so it never contends with the prelude — but it does not settle a clash with a module that also declares the name, which is a separate pair the compiler does not yet arbitrate ([#1312](https://github.com/aallan/vera/issues/1312)). +A module's data type may also collide with one the prelude provides (`Option`, `Result`, `Ordering`, `UrlParts`, `Json`, `HtmlNode`, `Request`, `Response`). One name carries one layout in the compiled program, so the two contend when their shapes differ — different constructors, a different constructor order, different field types, or a different number of type parameters (their names are free, since they are matched by position). The compiler then reports **E621** at the module's declaration; rename it there, or give it the prelude's shape. A module declaring `data Json` is legal on its own, because the prelude injects `Json` only when the entry program uses it; `Option`, `Result`, `Ordering` and `UrlParts` are in every program, so a differently-shaped module declaration of one of those always contends. Declaring the type in the **entry** file instead suppresses the prelude's own, so it never contends with the prelude — but it does not settle a clash with a module that also declares the name, which is a separate pair the compiler does not yet arbitrate ([#1312](https://github.com/aallan/vera/issues/1312)). Type aliases and effect declarations are module-local and cannot be imported. If another module needs the same alias or effect, it must declare its own copy. diff --git a/TESTING.md b/TESTING.md index 31b0f2f4..9a10b133 100644 --- a/TESTING.md +++ b/TESTING.md @@ -6,7 +6,7 @@ This is the single source of truth for Vera's testing infrastructure, coverage d | Metric | Value | |--------|-------| -| **Tests** | 11,072 across 168 files (~155,000 lines of test code; 10,893 passed + 26 stress, 153 skipped) | +| **Tests** | 11,075 across 168 files (~155,000 lines of test code; 10,896 passed + 26 stress, 153 skipped) | | **Compiler code coverage** | 95% Python, 87% JavaScript (CI minimum: 80%) | | **Conformance programs** | 229 programs across 9 spec chapters, validating every language feature | | **Example programs** | 42, all validated through `vera check` + `vera verify` | @@ -79,8 +79,8 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_handler_op_ownership_1284.py` | 15 | 419 | #1284 — whose declaration a bare `get`/`put` call site denotes. The checker resolves user-fn-first (pinned directly: an over-applied `get` under a `handle[State]` reports the USER signature's arity), and codegen used to answer that question twice more and differently — the declared-effect row withheld the op when a function owned the name, the handler expression overwrote unconditionally. Four shapes from check-green source, each asserted on the CHECKER's value and on the dispatch target in the emitted WAT: a handled body returning the cell instead of the function's answer (silently 5 for 4), a `@Bool`-returning user `get` whose module WASM validation rejected, same-family nesting refused outright with a spurious `[E602]` naming a State operation the source never contained, and different-family nesting emitting the enclosing cell's getter at the wrong width. A parametrized differential runs all five shapes (the four plus a user `put`) as one table with each case's pre-fix behaviour recorded, so a case that stops distinguishing the two answers is visible rather than vacuous; the controls — an unshadowed handler and an unshadowed declared row, both of which must still reach the intrinsics — are what a fix that simply stopped installing the ops would fail, and `new(State)` under a shadowed op name pins that the #1285 family registry composes with this | | `test_new_state_family_1285.py` | 9 | 323 | #1285 — which cell `new(State)` reads under a multi-`State` effect row. `old()` has been family-keyed since #1205/#1209 while `new()` read the name-keyed op registry, so the two sides of one `ensures` clause read different cells: `effects(, State>)` with `ensures(new(State) == …)` was check-green and verify-green, put `state_get_Int`'s i64 into the Bool comparison's `i32.eq`, and died at load. Three multi-row cases — the width-mismatched shape that could not load, an `Int`/`Nat` pair that loaded and answered about the wrong cell, and `old()` beside `new()` of one family, whose unchanged-cell claim the runtime refuted on a contract the verifier had discharged — plus the single-`State` and alias-spelled controls the whole existing corpus exercises. Each cell is seeded from a caller's handler at a value the other cell is not holding, so a wrong-cell read cannot coincide with the right answer, and a deliberately false postcondition asserts the Tier 3 runtime check really traps, without which every "the program runs" assertion here would prove nothing | | `test_adt_membership_scope_1253.py` | 5 | 334 | #1253 — a checker↔codegen DIFFERENTIAL over one module's slot table. `_adt_layouts` is one map across every absorbed namespace, so a sibling module's ADTs were members of a module that never imported them while the checker kept the name opaque: `['Array', 'Array']` against `['Array', 'Array']` for the same signature. Each case renders the module's parameters through `vera.naming` twice — once against the environment the checker binds that module's declarations in (built by the production `_modules_visible_to` + `check_program` path, not a rebuild of it) and once against codegen's `_alias_env` inside `_module_alias_scope` — and asserts both the agreement and the checker's own value, so an alignment on the WRONG name still fails. Three membership cases (an unimported public sibling, a private sibling, and the imported positive control that is green before and after — what separates scoping the membership from erasing cross-module ADTs) plus the entry program's own view, which must keep seeing the ADT it imports by name | -| `test_prelude_decl_stamp_1287.py` | 4 | 227 | #1287 — the prelude's declaration-index block is a fact about the prelude. `_stamp_decl_order` guarded the PRELUDE write on `_decl_order`, the active (main-file) namespace, so a main-file `type Option = Int` — accepted under §8.4.1, and not a `data`, so it does not suppress the prelude's own `Option` — made the guard fire and left `Option` out of `_prelude_decl_order` entirely, with every later prelude declaration shifted one place earlier because the counter never advanced. That map is the base layer under every module's index space (`{**prelude, **module_own}`), so the wrong index reached `AliasEnv.data_types` as `_BUILTIN_DECL_INDEX`. Stated as an INVARIANCE — the same program with and without the shadowing alias must stamp an identical prelude block — plus the module-namespace index it feeds, and a control that the main file's own stamp still wins its own namespace (which a fix stamping `_decl_order` unconditionally would break) | -| `test_prelude_adt_namespace_1277.py` | 49 | 810 | #1277 — one file's `data Json` must not evict the prelude's from another namespace, and a module declaration contending with a prelude one must be loud. Three halves, pinned by disjoint cases so a regression in any is attributable. **Acceptance battery**: all eight prelude ADT names × {module declares it alone, entry also uses the prelude's}, asserting that no cell reports `[E602]`/`[E620]` and that a cell which does not report `[E621]` emits every public function the entry declares — the silent-drop check, and the guard against the rail's original four-of-eight coverage returning (the layout harvest skips a built-in name, so a layout-keyed rail saw `data Json` and never `data Option`). Plus two-declaring-module cells in both import orders — the entry's import order is derived from the parametrization, and the both-differ cell asserts the two reports arrive in that order, so the pair cannot quietly become one program compiled twice — covering restate+differ, both-differ, both-restate, and a non-prelude control that must stay E609's, because the rail asks every declarer and a first-wins lookup made it order-dependent. Plus the restatement control for all eight: a module that restates the prelude's shape shares the one layout, compiles and runs, and must not be refused — measured legal at the branch point, and refused by the rail's first form for four of them. **Rail detail**: severity, the module's own file and line, the description naming the type and the module, the empty exports, and `cmd_compile` returning 1 over a `cmd_check`-green program. **Floor**: the checker registers the prelude ADTs in every `TypeEnv` unconditionally, so codegen's membership must too; asserted as a differential against the checker's own `data_types` in a module namespace, and on the entry namespace's member set for the issue's measured shape. `prelude_adt_names()` is compared against `inject_prelude` itself, and `data_decl_shape` is pinned on both directions that matter — a renamed type parameter is the same layout, a reordered constructor is not | +| `test_prelude_decl_stamp_1287.py` | 4 | 240 | #1287 — the prelude's declaration-index block is a fact about the prelude. `_stamp_decl_order` guarded the PRELUDE write on `_decl_order`, the active (main-file) namespace, so a main-file `type Option = Int` — accepted under §8.4.1, and not a `data`, so it does not suppress the prelude's own `Option` — made the guard fire and left `Option` out of `_prelude_decl_order` entirely, with every later prelude declaration shifted one place earlier because the counter never advanced. That map is the base layer under every module's index space (`{**prelude, **module_own}`), so the wrong index reached `AliasEnv.data_types` as `_BUILTIN_DECL_INDEX`. Stated as an INVARIANCE — the same program with and without the shadowing alias must stamp an identical prelude block — plus the module-namespace index it feeds, and a control that the main file's own stamp still wins its own namespace (which a fix stamping `_decl_order` unconditionally would break) | +| `test_prelude_adt_namespace_1277.py` | 52 | 983 | #1277 — one file's `data Json` must not evict the prelude's from another namespace, and a module declaration contending with a prelude one must be loud. Three halves, pinned by disjoint cases so a regression in any is attributable. **Acceptance battery**: all eight prelude ADT names × {module declares it alone, entry also uses the prelude's}, asserting that no cell reports `[E602]`/`[E620]` and that a cell which does not report `[E621]` emits every public function the entry declares — the silent-drop check, and the guard against the rail's original four-of-eight coverage returning (the layout harvest skips a built-in name, so a layout-keyed rail saw `data Json` and never `data Option`). Plus two-declaring-module cells in both import orders — the entry's import order is derived from the parametrization, and the both-differ cell asserts the two reports arrive in that order, so the pair cannot quietly become one program compiled twice — covering restate+differ, both-differ, both-restate, and a non-prelude control that must stay E609's, because the rail asks every declarer and a first-wins lookup made it order-dependent. Plus the restatement control for all eight: a module that restates the prelude's shape shares the one layout, compiles and runs, and must not be refused — measured legal at the branch point, and refused by the rail's first form for four of them. **Rail detail**: severity, the module's own file and line, the description naming the type and the module, the empty exports, and `cmd_compile` returning 1 over a `cmd_check`-green program. **Floor**: the checker registers the prelude ADTs in every `TypeEnv` unconditionally, so codegen's membership must too; asserted as a differential against the checker's own `data_types` in a module namespace, and on the entry namespace's member set for the issue's measured shape. `prelude_adt_names()` is compared against `inject_prelude` itself, and `data_decl_shape` is pinned on the directions that matter — a renamed type parameter is the same layout, a reordered constructor is not, an alias-spelled restatement keys EQUAL to the prelude's, and a type parameter shadows an alias of its own name. Each declaration is resolved through the aliases of the namespace it was written in, one side only: the two whole-program cells pin both directions of that — a module restating the prelude through `type Payload = String;` must compile, and a module hiding a mismatch behind `type Array = Int;` must not | | `test_import_visibility_entry_point_1244.py` | 6 | 269 | #1244 — `vera check` reports the same diagnostics whether it was given a module or a file that imports it. Registration alone says what a module DECLARES; the importer never checked its bodies, so a name a module never imported was rejected standalone (E200, §8.5.1) and accepted in silence through an importer. Written as EQUALITY between the two entry points rather than as "the importer warns", because the property is agreement — a future change making the standalone verdict lenient would satisfy a one-sided assertion and must fail here on the standalone leg. Six cases: the leaked unimported name, the honest control that imports what it uses (green before and after, so the new body check is the visibility rule rather than a blanket rejection of cross-module programs), the issue's type-error-through-importer shape (an `@Int` call bound to a `@Bool` slot: check-clean, Tier-1, failing at compile), a diamond proving each module is reported ONCE (the body check is memoised by path across nested checkers), and both entry points into an import cycle proving the memo terminates it | | `test_clone_body_declaring_module_1241_1243.py` | 5 | 344 | #1241 + #1243 — an imported generic's clone body resolves its bare calls in the DECLARING module, on both sides. The verifier's lexical lookup fell through to the importer's registry (`_declaring_module_scope` swapped the naming env, source and file but not the function registry) and codegen's clone-emission door was the one door that did not thread the module's intra-rename map, so `glib`'s `gen` called the importer's `need`. The two halves are one routing rule, and the tests are written so neither passes alone: each case asserts the `vera verify` verdict AND the `vera run` value together, so the verifier half alone (which makes verify clean while the compiled program still traps on the postcondition it just proved — the measured false Tier-1) fails the same test the codegen half alone (right value, verify still refusing) fails. Every expected value comes from the module verified and run STANDALONE, never from what the importer produces. Shapes: a private direct callee, a two-hop private chain, the type-discriminating pair (`@Int` vs `@Bool` — check-green source that emitted invalid WASM), and the unshadowed-callee control that was correct before and after, which is what pins the defect to the SHADOWED name rather than to cross-module calls in general | | `test_module_generic_namespace_1274.py` | 24 | 1,015 | #1274 — a module generic that does not own the importer's bare name is reached under `mod$$name`. Pre-fix only PRIVATE module generics were routed that way (#1000), so a PUBLIC one collided with the importer's same-named generic in the clone-name space: both files' `gen2` mangled to one `gen2$Bool`, one overwrote the other, and the module's own body ran the importer's — a **false Tier-1** (`check`/`verify` clean, the module's proved `ensures` violated at run: 999 where the declaring module answers 111). The full visibility matrix (module generic × importer generic), the import-filter dimension (out-of-filter, in-filter, wildcard), the unshadowed-out-of-filter cell that assembled to `unknown func $gen2`, and a type-discriminating shape whose two clones have different WAT result types. Each cell asserts the verify VERDICT and the runtime VALUE together in one test — a clean verify beside a violated postcondition IS the bug, so splitting them across sibling tests would let it hide — against the standalone library as oracle, and re-checks that the importer's own generic still answers its own value. The per-module both-sides differential lives in `test_monomorphize_differential.py`. Two further families joined after the adversarial round: the module→module **hop** — a module's bare call to a DIFFERENT module's qualified-only generic, which the per-module classification never rerouted, in both a LOUD spelling (a contract pins the answer, so a captured call traps) and a SILENT one (every contract admits both answers, so only the value distinguishes them) plus the two-hop shape where the entry never imports the declaring module at all; and the **shared-input** pair, which pins that the two sides compute the importer's occupied bare names identically — codegen reads them after Pass 0's helper renames, the verifier from the pre-transform AST, and a non-generic `where`-helper named `gen2` made the same imported generic bare-name-owning on one side and qualified-only on the other. The idempotence of that derivation is asserted directly across BOTH Pass-0 transforms and their composition — over a fixture carrying every helper shape it distinguishes (non-generic under non-generic, under a generic parent, under a generic helper, and a generic helper), since a fixture missing one would let a partial assertion look total — with each shape's membership pinned individually beside it, because idempotence alone would hold for a derivation that answered the same WRONG set every time. Two more families close the visibility dimension: the **transitive** one, driven through the production `ModuleResolver` (a hand-built `ResolvedModule` defaults `direct=True` and would never reach the path), asserting that a module reached only transitively has ALL its generics qualified-only — the entry's namespace does not hold them at all; and the **user-written qualified call** (`deep::gen(true)` where the importer declares its own `gen`), which must key its instantiation to the module's declaration rather than to whoever owns the bare name | @@ -222,7 +222,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_doc_builtin_shadowing.py` | 8 | 107 | `check_doc_builtin_shadowing.py` gate ([#819](https://github.com/aallan/vera/issues/819)): reject-set membership (opaque built-ins in, overridable combinators out), top-level + `where`-block `fn ` definitions flagged, non-built-in / overridable / prose-mention ignored, and the shipped docs are currently clean | | `test_runtime_traps.py` | 75 | 2,877 | Runtime trap categorisation (#516 Stage 1), out-of-bounds host-read bounds check (#1145), stdout/stderr-on-trap preservation (#522), `IO.print` live tee (#543), and trap source backtrace (#516 Stage 2): `_classify_trap` per-`kind` mapping (`divide_by_zero`/`out_of_bounds`/`stack_exhausted`/`unreachable`/`overflow`/`contract_violation`/`unknown`), `WasmTrapError` shape + `RuntimeError` substitutability, end-to-end `cmd_run` text + JSON envelopes including `trap_kind`, captured `stdout`, captured `stderr`, JSON-mode "no stderr leak" invariant, cross-stream code-order regression using merged `redirect_stdout`/`redirect_stderr`, the v0.0.123 tee suite (live streaming, write-count + order preservation, JSON-mode tee suppression, trap preservation invariant under tee, per-write flush count, default-execute silence), and the v0.0.124 source-mapping suite — `_resolve_trap_frames` unit tests covering user-fn / built-in / built-in-prefix / monomorphized base-name fallback / unknown-name / no-frames-attribute / leaf-first ordering preservation; end-to-end `cmd_run` text-mode + JSON-mode backtrace including the **leaf-first** ordering invariant; contract-violation backtrace in both text and JSON modes; direct `execute()` `WasmTrapError.frames` attachment; **suppression marker** for collapsed leading runtime-helper frames (mocked `vera.codegen.execute` with synthetic `is_builtin=True` leaf frames so the collapse logic is testable deterministically); source-map population for top-level fns + lifted closures (with span-value assertion against the closure literal's exact line range); and the no-builtin-leakage regression that pins built-in helpers (`alloc` / `gc_collect` / `contract_fail`) NOT being registered in `fn_source_map`; plus the v0.0.125 Stage 3 suite (`#547`) — text-mode `Fix:` block surfacing with position-ordering invariant (Fix appears after the source backtrace), text-mode block suppression for `contract_violation` (no empty header noise), JSON-mode `fix` field always-present (schema stability) including the empty-string case, `_TRAP_FIX_PARAGRAPHS` table-completeness assertion (every kind in the taxonomy has a Fix paragraph entry), and the column-wrap invariant (~76 chars max per line, two-space indent under the `Fix:` heading); plus the UTF-8 hardening suite **`TestHostPrintInvalidUtf8589`** (`#589` / `#592`) — after `#592` centralised the `errors="replace"` invariant into the single `vera.runtime.text.safe_utf8_decode` helper — reached only through a shared `_slice_and_decode` helper (`vera/runtime/heap.py`) that the three WASM-memory string readers (`_read_wasm_string` and `_read_string_export` there, and `vera/wasm/markdown.py::_read_string`) delegate to, with the `host_print` / `host_stderr` / `host_contract_fail` host imports and the String-return extractor in `execute()` routing through those readers rather than decoding inline: one helper unit test pinning the invariant once (invalid bytes → U+FFFD, valid + empty pass through), three wire-real end-to-end tests that drive the **production** readers (`_read_wasm_string` / markdown `_read_string` behind a synthetic-WAT `probe` host import; `_read_string_export` against a real exported memory, also covering its out-of-bounds → `None` pointer-fallback) over a region seeded with invalid UTF-8 — so a strict-decode regression surfaces as a `UnicodeDecodeError` escaping wasmtime's trampoline, and the host imports / extractor are transitively covered — and one synthetic-WAT end-to-end test that imports `vera.print` and calls it with raw invalid UTF-8 bytes to pin the wasmtime-trampoline fact independently (a Python `UnicodeDecodeError` inside a host import escapes as a "python exception" cause iff the host decode is strict); the six pre-`#592` structural source-grep assertions were retired by the centralisation; plus the Ctrl-C-during-host-import suite **`TestHostSleepKeyboardInterrupt`** ([#595](https://github.com/aallan/vera/issues/595) / [#599](https://github.com/aallan/vera/issues/599)) — after the v0.0.160 relocation to a single `except KeyboardInterrupt` handler in `execute()` (enabled by `wasmtime>=45.0.0`'s `except BaseException` trampoline fix): one structural assertion that the four per-host-import `raise _VeraExit(130)` guards are gone and the centralized handler maps to `exit_code=130`, plus four end-to-end tests that compile real Vera programs calling `IO.sleep(...)`, `IO.read_char(())`, a mocked fused `await` ([#841](https://github.com/aallan/vera/issues/841) — `Future.result()` patched to interrupt), and a live in-flight fused `await` (no mocking — `_thread.interrupt_main()` fired only once the server confirms the request arrived, handler then released so the executor teardown has a real worker to wait out; post-[#848](https://github.com/aallan/vera/issues/848) the progress print precedes the `async(...)`, so program order makes its stdout assertion deterministic), raise `KeyboardInterrupt` from inside the blocking call, and assert the program exits with `ExecuteResult.exit_code == 130` (pre-interrupt stdout preserved) instead of a raw Python traceback escaping wasmtime's trampoline | | `test_serve.py` | 8 | 189 | #305 `vera serve` driver end-to-end: GET/POST echo round-trips (method/path/headers/body cross the host↔guest boundary via `build_request_adt` / `decode_response_adt`), handler status propagation, runtime contract violation → 500 with `trap_kind` JSON, `State` isolation across requests (instance-per-request pinned), and clean `make_server` validation errors (missing / wrong-signature `handle`), and an eager-GC round-trip pinning the Request builder's shadow-rooting; all on ephemeral ports | -| `test_wasi_target.py` | 269 | 2,142 | #237 WASI Preview 2 target (spec chapter 13): component emission validated live against the real wasmtime host — parse (`Component(engine, wat)`), instantiate (`Linker.add_wasip2()` + `WasiConfig`), and execute (stdout/stderr capture, env, argv incl. a 500-arg GC-pressure stress and a >64 KiB arena-cap trap, preopen file round-trips + errno mapping, stdin incl. UTF-8 multibyte, clocks, random bounds, exit, contract-violation text on WASI stderr, overflow); the family gate (clean diagnostic naming unsupported families, never a silent fallback); the core-emission pin (default `--target wasm` WAT untouched); `cmd_compile`/`cmd_run --target wasi-p2` CLI integration (binary component artifact, `--wat` component text, JSON envelopes, trap-kind classification through the component boundary, exit-code 0/1 degradation, `--fn` rejection); the `execute_wasi_p2` host runner (env passthrough, argv, stderr capture, String-main `wasi:cli/run` fallback); the **dual-target conformance differential** (all 166 run-level conformance programs driven under both targets, byte-identical stdout/stderr required — 116 are dual-tested and 50 skip *loudly* rather than passing silently: 43 whose compiled WAT imports a host family outside `IO`/`Random` (`state`, `map`, `json`, `set`, `decimal`, `html`, `md`, `regex`, `db`), 6 with no public zero-argument `main`, and 1 calling a nondeterministic op. The excluded set is defined by those three properties rather than by a filename list, so it stays accurate as programs are added); a stock-`wasmtime`-CLI smoke test (skips when the CLI is not installed); and the Stage-D **server world** (`world="server"`): incoming-handler emission pins (adapter lift, 32-slot dispatch table, @0.2.0 version pin, no `wasi:cli/run`), #305 handler validation + server family-gate diagnostics (rejected IO ops, non-String map instantiations, unsupported families), the cli-world pin (default emission carries no server machinery), Request/Response layout tripwires, and a stock-`wasmtime serve` smoke battery (host-vs-served differential over a method/path/header/body matrix incl. duplicate-header later-wins, in-guest map-op order parity, `IO.print` console routing, trap→500 with symbolized backtrace + violation text, graceful 500s for forbidden headers and out-of-range status, a 1 MiB GC-stress echo, and an eager-GC shadow-push mutation validation; skips when the CLI is not installed) | +| `test_wasi_target.py` | 269 | 2,142 | #237 WASI Preview 2 target (spec chapter 13): component emission validated live against the real wasmtime host — parse (`Component(engine, wat)`), instantiate (`Linker.add_wasip2()` + `WasiConfig`), and execute (stdout/stderr capture, env, argv incl. a 500-arg GC-pressure stress and a >64 KiB arena-cap trap, preopen file round-trips + errno mapping, stdin incl. UTF-8 multibyte, clocks, random bounds, exit, contract-violation text on WASI stderr, overflow); the family gate (clean diagnostic naming unsupported families, never a silent fallback); the core-emission pin (default `--target wasm` WAT untouched); `cmd_compile`/`cmd_run --target wasi-p2` CLI integration (binary component artifact, `--wat` component text, JSON envelopes, trap-kind classification through the component boundary, exit-code 0/1 degradation, `--fn` rejection); the `execute_wasi_p2` host runner (env passthrough, argv, stderr capture, String-main `wasi:cli/run` fallback); the **dual-target conformance differential** (all 168 run-level conformance programs driven under both targets, byte-identical stdout/stderr required — 118 are dual-tested and 50 skip *loudly* rather than passing silently: 43 whose compiled WAT imports a host family outside `IO`/`Random` (`state`, `map`, `json`, `set`, `decimal`, `html`, `md`, `regex`, `db`), 6 with no public zero-argument `main`, and 1 calling a nondeterministic op. The excluded set is defined by those three properties rather than by a filename list, so it stays accurate as programs are added); a stock-`wasmtime`-CLI smoke test (skips when the CLI is not installed); and the Stage-D **server world** (`world="server"`): incoming-handler emission pins (adapter lift, 32-slot dispatch table, @0.2.0 version pin, no `wasi:cli/run`), #305 handler validation + server family-gate diagnostics (rejected IO ops, non-String map instantiations, unsupported families), the cli-world pin (default emission carries no server machinery), Request/Response layout tripwires, and a stock-`wasmtime serve` smoke battery (host-vs-served differential over a method/path/header/body matrix incl. duplicate-header later-wins, in-guest map-op order parity, `IO.print` console routing, trap→500 with symbolized backtrace + violation text, graceful 500s for forbidden headers and out-of-range status, a 1 MiB GC-stress echo, and an eager-GC shadow-push mutation validation; skips when the CLI is not installed) | ## Conformance Suite diff --git a/docs/SKILL.md b/docs/SKILL.md index 1c542797..8f38ec17 100644 --- a/docs/SKILL.md +++ b/docs/SKILL.md @@ -1909,7 +1909,7 @@ Cross-module compilation uses a flattening strategy: imported function bodies ar If two imported modules define a function, data type, or constructor with the same name, the compiler reports an error (E608/E609/E610) listing both conflicting modules. Rename one of the conflicting declarations in the source module to resolve the collision. Local definitions shadow imported names without error. -A module's data type may also collide with one the prelude provides (`Option`, `Result`, `Ordering`, `UrlParts`, `Json`, `HtmlNode`, `Request`, `Response`). One name carries one layout in the compiled program, so the two contend when their shapes differ — different constructors, a different constructor order, or different field types. The compiler then reports **E621** at the module's declaration; rename it there, or give it the prelude's shape. A module declaring `data Json` is legal on its own, because the prelude injects `Json` only when the entry program uses it; `Option`, `Result`, `Ordering` and `UrlParts` are in every program, so a differently-shaped module declaration of one of those always contends. Declaring the type in the **entry** file instead suppresses the prelude's own, so it never contends with the prelude — but it does not settle a clash with a module that also declares the name, which is a separate pair the compiler does not yet arbitrate ([#1312](https://github.com/aallan/vera/issues/1312)). +A module's data type may also collide with one the prelude provides (`Option`, `Result`, `Ordering`, `UrlParts`, `Json`, `HtmlNode`, `Request`, `Response`). One name carries one layout in the compiled program, so the two contend when their shapes differ — different constructors, a different constructor order, different field types, or a different number of type parameters (their names are free, since they are matched by position). The compiler then reports **E621** at the module's declaration; rename it there, or give it the prelude's shape. A module declaring `data Json` is legal on its own, because the prelude injects `Json` only when the entry program uses it; `Option`, `Result`, `Ordering` and `UrlParts` are in every program, so a differently-shaped module declaration of one of those always contends. Declaring the type in the **entry** file instead suppresses the prelude's own, so it never contends with the prelude — but it does not settle a clash with a module that also declares the name, which is a separate pair the compiler does not yet arbitrate ([#1312](https://github.com/aallan/vera/issues/1312)). Type aliases and effect declarations are module-local and cannot be imported. If another module needs the same alias or effect, it must declare its own copy. diff --git a/docs/llms-full.txt b/docs/llms-full.txt index eede9fc7..259ceeee 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -1915,7 +1915,7 @@ Cross-module compilation uses a flattening strategy: imported function bodies ar If two imported modules define a function, data type, or constructor with the same name, the compiler reports an error (E608/E609/E610) listing both conflicting modules. Rename one of the conflicting declarations in the source module to resolve the collision. Local definitions shadow imported names without error. -A module's data type may also collide with one the prelude provides (`Option`, `Result`, `Ordering`, `UrlParts`, `Json`, `HtmlNode`, `Request`, `Response`). One name carries one layout in the compiled program, so the two contend when their shapes differ — different constructors, a different constructor order, or different field types. The compiler then reports **E621** at the module's declaration; rename it there, or give it the prelude's shape. A module declaring `data Json` is legal on its own, because the prelude injects `Json` only when the entry program uses it; `Option`, `Result`, `Ordering` and `UrlParts` are in every program, so a differently-shaped module declaration of one of those always contends. Declaring the type in the **entry** file instead suppresses the prelude's own, so it never contends with the prelude — but it does not settle a clash with a module that also declares the name, which is a separate pair the compiler does not yet arbitrate ([#1312](https://github.com/aallan/vera/issues/1312)). +A module's data type may also collide with one the prelude provides (`Option`, `Result`, `Ordering`, `UrlParts`, `Json`, `HtmlNode`, `Request`, `Response`). One name carries one layout in the compiled program, so the two contend when their shapes differ — different constructors, a different constructor order, different field types, or a different number of type parameters (their names are free, since they are matched by position). The compiler then reports **E621** at the module's declaration; rename it there, or give it the prelude's shape. A module declaring `data Json` is legal on its own, because the prelude injects `Json` only when the entry program uses it; `Option`, `Result`, `Ordering` and `UrlParts` are in every program, so a differently-shaped module declaration of one of those always contends. Declaring the type in the **entry** file instead suppresses the prelude's own, so it never contends with the prelude — but it does not settle a clash with a module that also declares the name, which is a separate pair the compiler does not yet arbitrate ([#1312](https://github.com/aallan/vera/issues/1312)). Type aliases and effect declarations are module-local and cannot be imported. If another module needs the same alias or effect, it must declare its own copy. @@ -3258,7 +3258,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 11,072 tests, including a 229-program conformance suite +- 11,075 tests, including a 229-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/tests/test_prelude_adt_namespace_1277.py b/tests/test_prelude_adt_namespace_1277.py index 5143ea0c..03f89b70 100644 --- a/tests/test_prelude_adt_namespace_1277.py +++ b/tests/test_prelude_adt_namespace_1277.py @@ -741,6 +741,179 @@ def test_two_modules_declaring_a_NON_prelude_name_stay_e609( assert exports == [] +# A restatement of the prelude's `UrlParts` spelled through the module's own +# alias. Structurally the prelude's type; syntactically nothing like it. +_ALIAS_RESTATE = """ +type Payload = String; + +private data UrlParts { + UrlParts(Payload, Payload, Payload, Payload, Payload) +} + +public fn probe(@Int -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + match UrlParts("a", "b", "c", "d", "e") { + UrlParts(@String, @String, @String, @String, @String) -> @Int.0 + } +} +""" + +# The reverse: a module alias named after something the PRELUDE's own +# declaration spells, hiding a layout that does not fit behind identical +# syntax. `Array` here is the module's `Int`. +_ALIAS_HIDDEN_MISMATCH = """ +type Array = Int; + +private data Json { + JNull, + JBool(Bool), + JNumber(Float64), + JString(String), + JArray(Array), + JObject(Map) +} + +public fn probe(@Int -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + match JNull { + JNull -> @Int.0, + JBool(@Bool) -> 1, + JNumber(@Float64) -> 2, + JString(@String) -> 3, + JArray(@Array) -> 4, + JObject(@Map) -> 5 + } +} +""" + +_MAIN_USES_URLPARTS = """import alib(probe); + +public fn consume(@UrlParts -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + match @UrlParts.0 { + UrlParts(@String, @String, @String, @String, @String) -> + string_length(@String.0) + } +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + probe(7) +} +""" + +_MAIN_USES_PRELUDE_JSON_VIA_ALIB = """import alib(probe); + +public fn depth(@Json -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + json_array_length(@Json.0) +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + probe(7) +} +""" + + +def test_an_alias_spelled_restatement_is_still_a_restatement( + tmp_path: Path, +) -> None: + """A module may write the prelude's type through its own alias. + + `type Payload = String;` and a `UrlParts` whose five fields are + `Payload` describes exactly the prelude's layout, so it shares the one + slot and must compile — but the two declarations have nothing in + common syntactically, and a shape key over raw spellings refused it + (measured: E621 on a `vera check`-green program §8.4.1 permits). The + module's declaration is canonicalized through the MODULE's own alias + maps, which §8.4.1 makes the only ones that may answer for it. + """ + gen = _compile(tmp_path, { + "alib.vera": _ALIAS_RESTATE, + "main.vera": _MAIN_USES_URLPARTS, + }) + result = gen._result # type: ignore[attr-defined] + codes = sorted({d.error_code for d in result.diagnostics if d.error_code}) + assert codes == [], codes + assert sorted(result.exports) == ["consume", "main"], result.exports + + +def test_an_alias_cannot_hide_a_layout_that_does_not_fit( + tmp_path: Path, +) -> None: + """The other direction, and the reason only ONE side is resolved. + + `type Array = Int;` makes the module's `JArray(Array)` an + `Int` field while the prelude's is a real array. The two spellings + are identical, so a raw-syntax key called them the same layout and + the program compiled with the module's `Json` in the slot and the + entry's `json_array_length` reading it — no diagnostic at all + (measured: `ok: true`, `exports == ['depth', 'main']`). Resolving + the module's side alone separates them; resolving the PRELUDE's side + through the same maps would collapse them again, which is what makes + this the control for that mutation. + """ + gen = _compile(tmp_path, { + "alib.vera": _ALIAS_HIDDEN_MISMATCH, + "main.vera": _MAIN_USES_PRELUDE_JSON_VIA_ALIB, + }) + result = gen._result # type: ignore[attr-defined] + codes = sorted({d.error_code for d in result.diagnostics if d.error_code}) + assert codes == ["E621"], codes + assert result.exports == [] + + +def test_the_shape_key_resolves_only_the_namespace_it_is_given() -> None: + """`data_decl_shape`'s alias argument, at the unit level. + + Two direct properties, because the rail depends on both: an alias + substitution makes a differently-spelled declaration key EQUAL to the + prelude's, and a type PARAMETER of the same name as an alias shadows + it (`_resolve_named`'s branch order) rather than being substituted. + """ + from vera.parser import parse_to_ast + from vera.prelude import data_decl_shape, prelude_data_decls + + prelude = prelude_data_decls() + decl = parse_to_ast( + "private data UrlParts {\n" + " UrlParts(Payload, Payload, Payload, Payload, Payload)\n}" + ).declarations[0].decl + aliases = {"Payload": parse_to_ast( + "type Payload = String;").declarations[0].decl.type_expr} + assert data_decl_shape(decl) != data_decl_shape(prelude["UrlParts"]) + assert data_decl_shape(decl, aliases, {}) == ( + data_decl_shape(prelude["UrlParts"])) + + # `T` is the declaration's own parameter, so an alias named `T` must + # not reach it — the shape stays positional (`#0`), not `Int`. + generic = parse_to_ast( + "private data Option { None, Some(T) }").declarations[0].decl + shadow = {"T": parse_to_ast( + "type T = Int;").declarations[0].decl.type_expr} + assert data_decl_shape(generic, shadow, {}) == ( + data_decl_shape(prelude["Option"])) + + def test_the_shape_key_ignores_parameter_names_and_not_tag_order() -> None: """`data_decl_shape` models the layout: positions, not spellings. diff --git a/tests/test_prelude_decl_stamp_1287.py b/tests/test_prelude_decl_stamp_1287.py index d2da6b6c..e061a48c 100644 --- a/tests/test_prelude_decl_stamp_1287.py +++ b/tests/test_prelude_decl_stamp_1287.py @@ -206,6 +206,19 @@ def test_the_prelude_stamp_is_idempotent_by_name() -> None: gen._stamp_decl_order("Nimbus", prelude=True) assert gen._prelude_decl_order["Nimbus"] == first + 1 + # The ALREADY-STAMPED branch, which is the one the fix turns on: a name + # the main file stamped first must still get its own prelude index, + # while `_decl_order` keeps the main file's. Exercised directly rather + # than only through the `_SHADOWING` compile, so the branch is pinned + # even if that program stops reaching it. + gen._stamp_decl_order("Cirrus") # main-file stamp + main_index = gen._decl_order["Cirrus"] + assert main_index >= 0, main_index + gen._stamp_decl_order("Cirrus", prelude=True) # prelude stamp after + assert gen._decl_order["Cirrus"] == main_index, "the shadow lost its slot" + assert gen._prelude_decl_order["Cirrus"] == first + 2, ( + "the prelude block skipped a name the main file had declared") + def test_the_main_file_declaration_still_wins_its_own_namespace( tmp_path: Path, diff --git a/vera/README.md b/vera/README.md index 74221183..8728c026 100644 --- a/vera/README.md +++ b/vera/README.md @@ -73,50 +73,50 @@ execute(compile_result, ...) # → run WASM via wasmtime |--------|------:|-------|---------|---------| | `grammar.lark` | 344 | Parse | LALR(1) grammar definition | *(consumed by Lark)* | | `parser.py` | 191 | Parse | Lark frontend, error diagnosis | `parse()`, `parse_file()` | -| `lexical.py` | 297 | Parse | Shared lexical scanning (comment spans, blanking) | `scan_comments()`, `blank_block_comments()` | +| `lexical.py` | 329 | Parse | Shared lexical scanning (comment spans, blanking) | `scan_comments()`, `blank_block_comments()` | | `transform.py` | 1,572 | Transform | Lark tree → AST transformer | `transform()` | -| `ast.py` | 895 | Transform | Frozen dataclass AST nodes, source formatting | `Program`, `Node`, `Expr`, `format_expr` | -| `types.py` | 814 | Type check | Semantic type representation | `Type`, `is_subtype()` | -| `prelude.py` | 1,008 | Type check | Standard prelude — built-in ADT and combinator injection | `inject_prelude()`, `prelude_adt_names()`, `overridable_builtin_names()` | -| `naming.py` | 789 | Type check | The ONE slot / slot-reference-key / State-Exn-family renderer (#1208, #1209) — the checker's rendering, as a total pure function over an `AliasEnv`, consumed by the checker, the monomorphizer, the verifier, the SMT layer, codegen, the tester, the LSP, and `vera check --explain-slots`. Also the ONE refinement-binder derivation, from the type expression for codegen's runtime guard (`refinement_binder_parts`) and from the predicate's own reference for the verifier and SMT layers (`predicate_binder_key`, #1226), both rendering through `slot_name`; and each consumer is handed the env of the module that DECLARED what it is rendering | `slot_name()`, `slot_ref_key()`, `family_name()`, `resolve_type_expr()`, `AliasEnv` | -| `slots.py` | 396 | Type check | Presentation over `naming.py`: slot resolution tables and their text/JSON rendering, plus the two scope walks the tables need (`forall` narrowing, `where`-helper nesting). The walks here that are NOT naming say so in their docstrings — the alias-opaque syntactic spelling for WASM representation questions, the last-resort name for a State/Exn cell family that resolves to none, and the bare-call ownership predicate the checker, codegen, and mono discovery all resolve a `get`/`put` call site through | `slot_table()`, `format_slot_table()`, `fn_slot_scope()`, `fn_scopes()`, `type_expr_slot_name()`, `family_fallback_name()`, `bare_call_denotes_user_fn()` | -| `environment.py` | 2,424 | Type check | Type environment, scope stacks, ability registry, all built-in registrations | `TypeEnv`, `AbilityInfo` | -| `checker/` | 6,684 | Type check | Two-pass type checker (mixin package) | `typecheck()` | -| ` core.py` | 1,153 | | TypeChecker class, orchestration, contracts, constraint validation | | -| ` resolution.py` | 486 | | AST TypeExpr → semantic Type, inference | | +| `ast.py` | 917 | Transform | Frozen dataclass AST nodes, source formatting | `Program`, `Node`, `Expr`, `format_expr` | +| `types.py` | 859 | Type check | Semantic type representation | `Type`, `is_subtype()` | +| `prelude.py` | 1,115 | Type check | Standard prelude — built-in ADT and combinator injection | `inject_prelude()`, `prelude_adt_names()`, `overridable_builtin_names()` | +| `naming.py` | 857 | Type check | The ONE slot / slot-reference-key / State-Exn-family renderer (#1208, #1209) — the checker's rendering, as a total pure function over an `AliasEnv`, consumed by the checker, the monomorphizer, the verifier, the SMT layer, codegen, the tester, the LSP, and `vera check --explain-slots`. Also the ONE refinement-binder derivation, from the type expression for codegen's runtime guard (`refinement_binder_parts`) and from the predicate's own reference for the verifier and SMT layers (`predicate_binder_key`, #1226), both rendering through `slot_name`; and each consumer is handed the env of the module that DECLARED what it is rendering | `slot_name()`, `slot_ref_key()`, `family_name()`, `resolve_type_expr()`, `AliasEnv` | +| `slots.py` | 427 | Type check | Presentation over `naming.py`: slot resolution tables and their text/JSON rendering, plus the two scope walks the tables need (`forall` narrowing, `where`-helper nesting). The walks here that are NOT naming say so in their docstrings — the alias-opaque syntactic spelling for WASM representation questions, the last-resort name for a State/Exn cell family that resolves to none, and the bare-call ownership predicate the checker, codegen, and mono discovery all resolve a `get`/`put` call site through | `slot_table()`, `format_slot_table()`, `fn_slot_scope()`, `fn_scopes()`, `type_expr_slot_name()`, `family_fallback_name()`, `bare_call_denotes_user_fn()` | +| `environment.py` | 2,327 | Type check | Type environment, scope stacks, ability registry, all built-in registrations | `TypeEnv`, `AbilityInfo` | +| `checker/` | 7,264 | Type check | Two-pass type checker (mixin package) | `typecheck()` | +| ` core.py` | 1,165 | | TypeChecker class, orchestration, contracts, constraint validation | | +| ` resolution.py` | 535 | | AST TypeExpr → semantic Type, inference | | | ` modules.py` | 285 | | Cross-module registration (C7b/C7c), plus the per-module body check that makes a module's diagnostics independent of which file `vera check` was given (#1244) | | -| ` registration.py` | 863 | | Pass 1 forward declarations, ability registration | | -| ` expressions.py` | 1,405 | | Expression synthesis (bidirectional), operators, statements | | +| ` registration.py` | 915 | | Pass 1 forward declarations, ability registration | | +| ` expressions.py` | 1,485 | | Expression synthesis (bidirectional), operators, statements | | | ` eq_ability.py` | 199 | | Eq ability derivation checks | | | ` sql.py` | 309 | | SQL literal-provenance resolution + placeholder counting (#309) | `resolve_literal_string()`, `count_placeholders()` | -| ` calls.py` | 1,597 | | Function/constructor/module/ability calls | | +| ` calls.py` | 1,631 | | Function/constructor/module/ability calls | | | ` control.py` | 735 | | If/match, patterns, effect handlers | | | `resolver.py` | 332 | Resolve | Module path resolution, parse cache | `ModuleResolver` | -| `monomorphize.py` | 3,357 | Resolve | Shared generic instantiation discovery + AST substitution (verifier and codegen); each clone's De Bruijn recount renders its binder names under the **origin module's** `AliasEnv`, the one its consumers rebuild the clone's scope with (#1208) | `substitute_type_vars()`, `resolve_type_alias()`, `canonicalize_type_aliases()` | -| `smt.py` | 3,141 | Verify | Z3 translation layer; reads each callee's contract in the module that declared it (`_callee_contract_scope`), swapping the naming env its slots render against and the registry its bare-name calls resolve in as one `CalleeScope` (#1208, #1225) | `SmtContext`, `SlotEnv`, `CalleeScope` | -| `verifier.py` | 9,177 | Verify | Contract verification; owns the per-module registries every rendering goes through — an imported callee's contract and an imported generic's clone are named, resolved, and quoted in the module that **declared** them (#1208, #1220, #1225) | `verify()` | -| `wasm/` | 26,604 | Compile | WASM translation layer (package) | `WasmContext`, `WasmSlotEnv`, `StringPool` | -| ` ├ context.py` | 1,265 | | Composed WasmContext, expression dispatcher, block translation | | -| ` ├ helpers.py` | 641 | | WasmSlotEnv, StateClauseEntry, StringPool, type mapping, array element helpers | | -| ` ├ inference.py` | 2,518 | | Type inference, slot/type utilities, operator tables | | -| ` ├ operators.py` | 2,778 | | Binary/unary operators, if, quantifiers, assert/assume, old/new | | -| ` ├ calls.py` | 1,196 | | Core dispatcher for `_translate_call` / `_translate_qualified_call`, generic resolution, shared element-type inference (domain mixins below) | | +| `monomorphize.py` | 3,380 | Resolve | Shared generic instantiation discovery + AST substitution (verifier and codegen); each clone's De Bruijn recount renders its binder names under the **origin module's** `AliasEnv`, the one its consumers rebuild the clone's scope with (#1208) | `substitute_type_vars()`, `resolve_type_alias()`, `canonicalize_type_aliases()` | +| `smt.py` | 3,289 | Verify | Z3 translation layer; reads each callee's contract in the module that declared it (`_callee_contract_scope`), swapping the naming env its slots render against and the registry its bare-name calls resolve in as one `CalleeScope` (#1208, #1225) | `SmtContext`, `SlotEnv`, `CalleeScope` | +| `verifier.py` | 9,446 | Verify | Contract verification; owns the per-module registries every rendering goes through — an imported callee's contract and an imported generic's clone are named, resolved, and quoted in the module that **declared** them (#1208, #1220, #1225) | `verify()` | +| `wasm/` | 27,524 | Compile | WASM translation layer (package) | `WasmContext`, `WasmSlotEnv`, `StringPool` | +| ` ├ context.py` | 1,292 | | Composed WasmContext, expression dispatcher, block translation | | +| ` ├ helpers.py` | 643 | | WasmSlotEnv, StateClauseEntry, StringPool, type mapping, array element helpers | | +| ` ├ inference.py` | 2,631 | | Type inference, slot/type utilities, operator tables | | +| ` ├ operators.py` | 2,798 | | Binary/unary operators, if, quantifiers, assert/assume, old/new | | +| ` ├ calls.py` | 1,313 | | Core dispatcher for `_translate_call` / `_translate_qualified_call`, generic resolution, shared element-type inference (domain mixins below) | | | ` ├ calls_arrays.py` | 2,694 | | `array_length` / `append` / `range` / `concat` / `slice` / `map` / `filter` / `fold` / `mapi` / `reverse` / `find` / `any` / `all` / `flatten` / `sort_by` | | | ` ├ calls_containers.py` | 1,304 | | Map, Set, Decimal (opaque-handle types) | | | ` ├ calls_encoding.py` | 2,210 | | Base64 and URL encoding/decoding/parsing | | -| ` ├ calls_handlers.py` | 2,305 | | Show/Hash ability dispatch, `handle[State]` and `handle[Exn]` | | +| ` ├ calls_handlers.py` | 2,513 | | Show/Hash ability dispatch, `handle[State]` and `handle[Exn]` | | | ` ├ calls_markup.py` | 400 | | JSON, HTML, Markdown, Regex, async/await (#841: fused concurrent lowering for `async(Http.get/post)`, identity otherwise) | | | ` ├ async_fusion.py` | 436 | | #841 fusion predicates — the single source of truth shared by the `_scan_io_ops` import pre-scan and the `WasmContext` async/await lowering | `fused_async_target()`, `await_needs_check()`, `compute_future_ret_fns()` | | ` ├ calls_math.py` | 635 | | `abs`, `min`, `max`, `floor`, `ceil`, `round`, `sqrt`, `pow`, Float64 predicates, numeric conversions | | | ` ├ calls_parsing.py` | 1,035 | | `parse_nat` / `parse_int` / `parse_bool` / `parse_float64` state machines | | | ` ├ calls_strings.py` | 4,185 | | All string ops (length, concat, slice, search, transform, split, join, chars/lines/words, reverse, trim_start/end, pad_start/end, char_to_upper/lower, classifiers) + to-string conversions; `_translate_strip` delegates to the trim helper to keep the whitespace predicate consistent | | -| ` ├ closures.py` | 549 | | Closures, anonymous functions, free variable analysis | | -| ` ├ data.py` | 1,510 | | Constructors, match expressions (incl. nested patterns), arrays, indexing | | +| ` ├ closures.py` | 582 | | Closures, anonymous functions, free variable analysis | | +| ` ├ data.py` | 1,515 | | Constructors, match expressions (incl. nested patterns), arrays, indexing | | | ` ├ markdown.py` | 651 | | WASM memory marshalling for MdInline/MdBlock ADTs | | | ` ├ json_serde.py` | 420 | | WASM memory marshalling for Json ADT | | | ` └ html_serde.py` | 261 | | WASM memory marshalling for HtmlNode ADT | | | `markdown.py` | 728 | Compile | Python Markdown parser/renderer (§9.7.3 subset) | `parse_markdown()`, `render_markdown()`, `has_heading()`, `has_code_block()`, `extract_code_blocks()` | -| `obligations/` | 725 | Verify | Reified proof obligations + warm incremental session (#222 A/B) | `ProofObligation`, `VerificationSession` | +| `obligations/` | 785 | Verify | Reified proof obligations + warm incremental session (#222 A/B) | `ProofObligation`, `VerificationSession` | | ` core.py` | 198 | | ProofObligation record: identity (content_key) + discharge outcome | | | ` cache.py` | 219 | | Invalidation keys (structural/callee/context hashes), DischargeCache | | | ` session.py` | 311 | | Warm-Z3 daemon: per-function replay vs re-verify in declaration order | | @@ -124,24 +124,24 @@ execute(compile_result, ...) # → run WASM via wasmtime | ` convert.py` | 218 | | Span/SourceLocation/LSP coordinate conversions, UTF-16 transcoding | | | ` documents.py` | 69 | | URI-keyed document store, full-text sync | | | ` features.py` | 374 | | Diagnostics + tier hints, hover, slot goto (keyed through `naming.slot_ref_key`, so parameterised and alias-spelled references resolve, and a `where` helper resolves in its own accumulated scope), hole completion | | -| ` extensions.py` | 146 | | vera/speculativeEdit proof-delta | | +| ` extensions.py` | 153 | | vera/speculativeEdit proof-delta | | | ` server.py` | 287 | | pygls wiring, single-session serialisation | | | ` workflows.py` | 608 | | Skill-layer workflows: enforced edit sequences (#222 F) | | -| `codegen/` | 18,981 | Compile | Codegen orchestrator (mixin package) | `compile()`, `execute()` | +| `codegen/` | 19,686 | Compile | Codegen orchestrator (mixin package) | `compile()`, `execute()` | | ` api.py` | 1,402 | | Public API, dataclasses, `compile()`/`execute()` orchestration, core IO host bindings (#421) | | | ` memory.py` | 105 | | Compile-time ADT layout helpers (`ConstructorLayout`, alignment) (#421) | | -| ` core.py` | 3,348 | | CodeGenerator class, orchestration, ability op rewriting (Pass 1.6), skip propagation to callers (#1100) | | -| ` modules.py` | 1,422 | | Cross-module registration + call detection (C7e), per-module alias + source scopes (#1111/#1186) — `_module_alias_scope` swaps the alias maps *and* the `AliasEnv` every codegen rendering goes through as one pair (#1208) | | +| ` core.py` | 3,361 | | CodeGenerator class, orchestration, ability op rewriting (Pass 1.6), skip propagation to callers (#1100) | | +| ` modules.py` | 1,425 | | Cross-module registration + call detection (C7e), per-module alias + source scopes (#1111/#1186) — `_module_alias_scope` swaps the alias maps *and* the `AliasEnv` every codegen rendering goes through as one pair (#1208) | | | ` registration.py` | 499 | | Pass 1 forward declarations, ADT layout | | -| ` monomorphize.py` | 1,534 | | Generic instantiation, type inference, ability constraint checking (Pass 1.5) | | -| ` functions.py` | 1,430 | | Function body compilation, GC prologue/epilogue (Pass 2) | | +| ` monomorphize.py` | 1,581 | | Generic instantiation, type inference, ability constraint checking (Pass 1.5) | | +| ` functions.py` | 1,455 | | Function body compilation, GC prologue/epilogue (Pass 2) | | | ` tail_position.py` | 106 | | Tail-position analysis for the function body compiler | | -| ` closures.py` | 1,040 | | Closure lifting, GC instrumentation | | +| ` closures.py` | 1,052 | | Closure lifting, GC instrumentation | | | ` contracts.py` | 1,337 | | Runtime pre/postconditions, old state snapshots, decreases termination guard (entry check-and-set, per-function chain state, ADT rank helpers, self-tail site checks); the refinement boundary guard derives its binder from `naming.refinement_binder_parts` and layers the erased-base skip and the nested-base E618 on top. Also the ONE derivation of what that guard layer lowers — `_tuple_component_guard_sites` decomposes a boundary tuple for the emitter, the return-epilogue gate and the host-import pre-scan alike, and `_signature_refinement_predicates` enumerates every predicate a signature will be guarded by (#1210) | | | ` assembly.py` | 1,502 | | WAT module assembly, `$alloc`, `$gc_collect` | | | ` compilability.py` | 1,004 | | Compilability checks; the two host-import pre-scans (State/Exn families and IO/Markdown/Regex builtins), walking each function's body, its contract predicates and every signature the guard layer will check — including closures', cycle-guarded | | | ` wasi.py` | 4,828 | | WASI Preview 2 component/adapter emitter — `--target wasi-p2` / `--world server` (#237, #853) | | -| `runtime/` | 4,785 | Execute | wasmtime host layer (#421): traps + per-effect host-binding families | `register_*()`, `WasmTrapError` | +| `runtime/` | 4,817 | Execute | wasmtime host layer (#421): traps + per-effect host-binding families | `register_*()`, `WasmTrapError` | | ` traps.py` | 493 | | `WasmTrapError`, `_classify_trap`, source-backtrace resolution | | | ` heap.py` | 1,376 | | WASM memory marshalling primitives, ADT/Option/Array/bucket codecs, `_ShadowGuard`, shared collection helpers | | | ` collections.py` | 16 | | `_VAL_WASM_TYPES` value-type dispatch table (shared by Map/Set) | | @@ -150,16 +150,16 @@ execute(compile_result, ...) # → run WASM via wasmtime | ` wasi_host.py` | 213 | | Built-in `wasi-p2` runner via `add_wasip2` — `vera run --target wasi-p2` (#237, #853) | | | ` server.py` | 150 | | `vera serve` HTTP driver for `handle(Request -> Response)` (#305) | | | `tester.py` | 1,285 | Test | Z3-guided input generation (parameter types resolved through `naming.py`; a TIER-3 target whose input constraints do not all translate is skipped naming the blocker rather than trialled, while a Tier-1-proved function is reported verified and never trialled at all), WASM execution, tier classification | `test()` | -| `formatter.py` | 1,951 | Format | Canonical code formatter | `format_source()` | -| `errors.py` | 812 | All | Diagnostic class, error hierarchy, error code registry | `Diagnostic`, `VeraError`, `ERROR_CODES` | +| `formatter.py` | 2,036 | Format | Canonical code formatter | `format_source()` | +| `errors.py` | 813 | All | Diagnostic class, error hierarchy, error code registry | `Diagnostic`, `VeraError`, `ERROR_CODES` | | `skip.py` | 241 | All | Codegen-internal control-flow exceptions behind structured skip diagnostics (#626) | `CodegenSkip`, `CodegenInvariantError` | | `introspect.py` | 127 | All | Payloads for `vera builtins` / `effects` / `errors --json` | `builtins_payload()`, `effects_payload()`, `errors_payload()` | -| `_since.py` | 375 | All | Best-effort `since` version attribution for built-ins, effects, abilities | | +| `_since.py` | 376 | All | Best-effort `since` version attribution for built-ins, effects, abilities | | | `browser/` | 138 | Execute | Browser runtime for compiled WASM (package) | `emit_browser_bundle()` | | ` ├ emit.py` | 137 | | Browser bundle emission (wasm + runtime + html) | `emit_browser_bundle()` | -| ` ├ runtime.mjs` | 3,303 | | Self-contained JS runtime: IO, State, Http, Inference, contracts, Markdown, Json, Html | | +| ` ├ runtime.mjs` | 3,660 | | Self-contained JS runtime: IO, State, Http, Inference, contracts, Markdown, Json, Html | | | ` └ harness.mjs` | 106 | | Node.js test harness for parity testing | | -| `cli.py` | 1,975 | All | CLI commands | `main()` | +| `cli.py` | 1,990 | All | CLI commands | `main()` | | `registration.py` | 126 | Type check | Shared function registration | `register_fn()` | Total: ~88,000 lines of Python + 344 lines of grammar + 3,409 lines of JavaScript. @@ -752,7 +752,7 @@ The `ERROR_CODES` dict in `errors.py` maps every code to a short description (15 ## Test Suite -Testing spans a **pytest suite** of 11,072 tests across 168 files — compiler-internals unit tests plus a **conformance suite** (229 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. +Testing spans a **pytest suite** of 11,075 tests across 168 files — compiler-internals unit tests plus a **conformance suite** (229 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. See **[TESTING.md](../TESTING.md)** for the comprehensive testing reference -- test file table, conformance suite details, compiler code coverage, language feature coverage, helper conventions, validation scripts, CI pipeline, and guidelines for adding tests. diff --git a/vera/codegen/core.py b/vera/codegen/core.py index 394665a0..2ec43d2a 100644 --- a/vera/codegen/core.py +++ b/vera/codegen/core.py @@ -1535,8 +1535,21 @@ def _contends_with_prelude( module_decl = self._find_module_data_decl(owner, prelude_decl.name) if module_decl is None: # pragma: no cover — defensive return True + # The module's declaration is canonicalized through the MODULE's own + # alias maps — §8.4.1 makes an alias module-local, so those are the + # only ones that may answer for it (#1111) — and the prelude's + # through nothing. One side only, in that direction: a restatement + # spelled through a module alias is still a restatement, while + # resolving the prelude's spelling through a module's aliases would + # let `type Array = Int;` collapse the prelude's `Array` + # onto the module's `Int` and share a layout that does not fit. return ( - data_decl_shape(module_decl) != data_decl_shape(prelude_decl) + data_decl_shape( + module_decl, + self._module_type_aliases.get(owner, {}), + self._module_type_alias_params.get(owner, {}), + ) + != data_decl_shape(prelude_decl) ) def _emit_prelude_adt_contention_error( diff --git a/vera/prelude.py b/vera/prelude.py index b9dd71bd..2afc0153 100644 --- a/vera/prelude.py +++ b/vera/prelude.py @@ -22,6 +22,7 @@ from typing import Mapping from vera import ast +from vera.monomorphize import canonicalize_type_aliases # #851 — synthetic origin filename for prelude-injected declarations. @@ -832,7 +833,11 @@ def prelude_adt_names() -> frozenset[str]: return frozenset(prelude_data_decls()) -def data_decl_shape(decl: ast.DataDecl) -> tuple[object, ...]: +def data_decl_shape( + decl: ast.DataDecl, + aliases: Mapping[str, ast.TypeExpr] | None = None, + alias_params: Mapping[str, tuple[str, ...]] | None = None, +) -> tuple[object, ...]: """*decl*'s LAYOUT identity — what two declarations must share (#1277). Two ``data`` declarations of one name can occupy codegen's single @@ -854,17 +859,44 @@ def data_decl_shape(decl: ast.DataDecl) -> tuple[object, ...]: own fields are all named types, so the coarse arm is never on both sides of a comparison. Different is the safe direction: it refuses a compile rather than sharing a layout that may not fit. + + *aliases* / *alias_params* canonicalize the field types first, and + belong to the namespace THIS declaration was written in — a module's + own alias maps for a module's declaration (§8.4.1 makes an alias + module-local, so no other namespace's may answer here; #1111/#1253). + Passing them for one side only is deliberate: a module that spells a + restatement through its own alias (`type Payload = String;`) means + the prelude's type and must not be refused, while resolving the + PRELUDE's spelling through a module's aliases would let a module + alias named after something the prelude spells — `type Array = + Int;` against the prelude's `JArray(Array)` — collapse two + incompatible layouts into one key. The prelude's declaration + resolves through nothing, because the prelude is its own namespace. + + A type PARAMETER shadows an alias of the same name (`_resolve_named`'s + branch order), so the declaration's own parameters are withheld from + the map before substitution. """ slots = { name: f"#{i}" for i, name in enumerate(decl.type_params or ()) } + if aliases: + visible = {k: v for k, v in aliases.items() if k not in slots} + params = { + k: v for k, v in (alias_params or {}).items() if k not in slots + } + def key(te: ast.TypeExpr) -> str: + return _type_shape_key( + canonicalize_type_aliases(te, dict(visible), dict(params)), + slots, + ) + else: + def key(te: ast.TypeExpr) -> str: + return _type_shape_key(te, slots) return ( len(decl.type_params or ()), tuple( - (ctor.name, tuple( - _type_shape_key(field, slots) - for field in (ctor.fields or ()) - )) + (ctor.name, tuple(key(field) for field in (ctor.fields or ()))) for ctor in decl.constructors ), ) From 05ae0ab9409224e336cea7d11e3c6384acc186fa Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 13:18:43 +0100 Subject: [PATCH 30/69] Refresh three free-form conformance counts in TESTING.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The counts the oracle gates were already current; these three are prose it does not read. The `test_conformance.py` row said the suite runs "across 219 programs" beside its own gated 1145 — the pre-union figure from this branch. The runbook said "parametrized — 1,130 tests", now 1,145, stated as its derivation (five stages × 229 entries) rather than a bare number, since the structure is what makes the arithmetic checkable: `TestConformance` carries one `@pytest.mark.parametrize` over the manifest and exactly five stage methods, and collection reports 1145. And a sweep for the same class found a third: the `test_module_generic_collision_1281.py` row cites the conformance total in passing, at #1311's 226. Only that shared number is touched there; the claim around it is #1311's own. Skip-changelog: documentation counts only, no behaviour change. Co-Authored-By: Claude --- TESTING.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/TESTING.md b/TESTING.md index 9a10b133..bbf08439 100644 --- a/TESTING.md +++ b/TESTING.md @@ -84,7 +84,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_import_visibility_entry_point_1244.py` | 6 | 269 | #1244 — `vera check` reports the same diagnostics whether it was given a module or a file that imports it. Registration alone says what a module DECLARES; the importer never checked its bodies, so a name a module never imported was rejected standalone (E200, §8.5.1) and accepted in silence through an importer. Written as EQUALITY between the two entry points rather than as "the importer warns", because the property is agreement — a future change making the standalone verdict lenient would satisfy a one-sided assertion and must fail here on the standalone leg. Six cases: the leaked unimported name, the honest control that imports what it uses (green before and after, so the new body check is the visibility rule rather than a blanket rejection of cross-module programs), the issue's type-error-through-importer shape (an `@Int` call bound to a `@Bool` slot: check-clean, Tier-1, failing at compile), a diamond proving each module is reported ONCE (the body check is memoised by path across nested checkers), and both entry points into an import cycle proving the memo terminates it | | `test_clone_body_declaring_module_1241_1243.py` | 5 | 344 | #1241 + #1243 — an imported generic's clone body resolves its bare calls in the DECLARING module, on both sides. The verifier's lexical lookup fell through to the importer's registry (`_declaring_module_scope` swapped the naming env, source and file but not the function registry) and codegen's clone-emission door was the one door that did not thread the module's intra-rename map, so `glib`'s `gen` called the importer's `need`. The two halves are one routing rule, and the tests are written so neither passes alone: each case asserts the `vera verify` verdict AND the `vera run` value together, so the verifier half alone (which makes verify clean while the compiled program still traps on the postcondition it just proved — the measured false Tier-1) fails the same test the codegen half alone (right value, verify still refusing) fails. Every expected value comes from the module verified and run STANDALONE, never from what the importer produces. Shapes: a private direct callee, a two-hop private chain, the type-discriminating pair (`@Int` vs `@Bool` — check-green source that emitted invalid WASM), and the unshadowed-callee control that was correct before and after, which is what pins the defect to the SHADOWED name rather than to cross-module calls in general | | `test_module_generic_namespace_1274.py` | 24 | 1,015 | #1274 — a module generic that does not own the importer's bare name is reached under `mod$$name`. Pre-fix only PRIVATE module generics were routed that way (#1000), so a PUBLIC one collided with the importer's same-named generic in the clone-name space: both files' `gen2` mangled to one `gen2$Bool`, one overwrote the other, and the module's own body ran the importer's — a **false Tier-1** (`check`/`verify` clean, the module's proved `ensures` violated at run: 999 where the declaring module answers 111). The full visibility matrix (module generic × importer generic), the import-filter dimension (out-of-filter, in-filter, wildcard), the unshadowed-out-of-filter cell that assembled to `unknown func $gen2`, and a type-discriminating shape whose two clones have different WAT result types. Each cell asserts the verify VERDICT and the runtime VALUE together in one test — a clean verify beside a violated postcondition IS the bug, so splitting them across sibling tests would let it hide — against the standalone library as oracle, and re-checks that the importer's own generic still answers its own value. The per-module both-sides differential lives in `test_monomorphize_differential.py`. Two further families joined after the adversarial round: the module→module **hop** — a module's bare call to a DIFFERENT module's qualified-only generic, which the per-module classification never rerouted, in both a LOUD spelling (a contract pins the answer, so a captured call traps) and a SILENT one (every contract admits both answers, so only the value distinguishes them) plus the two-hop shape where the entry never imports the declaring module at all; and the **shared-input** pair, which pins that the two sides compute the importer's occupied bare names identically — codegen reads them after Pass 0's helper renames, the verifier from the pre-transform AST, and a non-generic `where`-helper named `gen2` made the same imported generic bare-name-owning on one side and qualified-only on the other. The idempotence of that derivation is asserted directly across BOTH Pass-0 transforms and their composition — over a fixture carrying every helper shape it distinguishes (non-generic under non-generic, under a generic parent, under a generic helper, and a generic helper), since a fixture missing one would let a partial assertion look total — with each shape's membership pinned individually beside it, because idempotence alone would hold for a derivation that answered the same WRONG set every time. Two more families close the visibility dimension: the **transitive** one, driven through the production `ModuleResolver` (a hand-built `ResolvedModule` defaults `direct=True` and would never reach the path), asserting that a module reached only transitively has ALL its generics qualified-only — the entry's namespace does not hold them at all; and the **user-written qualified call** (`deep::gen(true)` where the importer declares its own `gen`), which must key its instantiation to the module's declaration rather than to whoever owns the bare name | -| `test_module_generic_collision_1281.py` | 20 | 778 | #1281 — E608 must not refuse two modules' PROVABLY DISTINCT generics. A generic emits nothing under its bare name, and since #1274 its clones live in a namespace chosen per OWNER, so the diamond (`base` public, `mid1` private, both named `gen`) cannot overwrite anything — and was refused outright, with `vera verify` returning rc=0 beside the refusal. Each door now answers its own module's generic (555 + 111) against the standalone oracles, and the emitted module carries `mod$…$mid1$gen$Bool` and `mod$…$base$gen$Bool` with nothing in the entry's bare clone namespace. The relaxation is gated on three conditions, each with its own cell: both declarations are top-level generics (a generic beside a non-generic keeps the refusal), at most one owns the bare name (asked of the predicate directly, since end to end the ambiguity gate catches that shape first), and no namespace can name both — a module importing two dependencies that each export `gen` resolves its own bare call to one of them, and neither spec §8.5 nor the checker says which: the checker's pick is a set-iteration artefact, and over eight runs of one unchanged program the type oracle accepted four times and was rejected four times (stable under a fixed `PYTHONHASHSEED`, varying with the seed, and identical whichever textual order the imports are written in), while codegen's reroute map is positional last-wins. Compiling the shape would pick a body by coin-flip, so it keeps its loud refusal (issue 1304). A namespace that declares its own `gen` is not ambiguous however many dependencies export one (§8.5.2). The registration half — a qualified-only generic contributing no bare `_fn_sigs` or `_fn_ret_type_exprs` entry — is pinned by two STRUCTURAL cells, one per table, and its docstring says why: with #1299's scope narrowing in place both withholdings are defence in depth, reverting them leaves every suite and all 226 conformance programs green, and they are kept only because four consumers read those tables per NAME and nothing but their current internals stops each from picking one | +| `test_module_generic_collision_1281.py` | 20 | 778 | #1281 — E608 must not refuse two modules' PROVABLY DISTINCT generics. A generic emits nothing under its bare name, and since #1274 its clones live in a namespace chosen per OWNER, so the diamond (`base` public, `mid1` private, both named `gen`) cannot overwrite anything — and was refused outright, with `vera verify` returning rc=0 beside the refusal. Each door now answers its own module's generic (555 + 111) against the standalone oracles, and the emitted module carries `mod$…$mid1$gen$Bool` and `mod$…$base$gen$Bool` with nothing in the entry's bare clone namespace. The relaxation is gated on three conditions, each with its own cell: both declarations are top-level generics (a generic beside a non-generic keeps the refusal), at most one owns the bare name (asked of the predicate directly, since end to end the ambiguity gate catches that shape first), and no namespace can name both — a module importing two dependencies that each export `gen` resolves its own bare call to one of them, and neither spec §8.5 nor the checker says which: the checker's pick is a set-iteration artefact, and over eight runs of one unchanged program the type oracle accepted four times and was rejected four times (stable under a fixed `PYTHONHASHSEED`, varying with the seed, and identical whichever textual order the imports are written in), while codegen's reroute map is positional last-wins. Compiling the shape would pick a body by coin-flip, so it keeps its loud refusal (issue 1304). A namespace that declares its own `gen` is not ambiguous however many dependencies export one (§8.5.2). The registration half — a qualified-only generic contributing no bare `_fn_sigs` or `_fn_ret_type_exprs` entry — is pinned by two STRUCTURAL cells, one per table, and its docstring says why: with #1299's scope narrowing in place both withholdings are defence in depth, reverting them leaves every suite and all 229 conformance programs green, and they are kept only because four consumers read those tables per NAME and nothing but their current internals stops each from picking one | | `test_lexical_fn_scope_1299.py` | 56 | 1,554 | #1299 — codegen's bare-call ownership table must be the CALL SITE's lexical scope. The #1284 predicate is one rule over two tables, and codegen's was `set(_fn_sigs)`: every symbol the whole compilation absorbed, including names the compiling body cannot see, so a bare `get(())` the checker resolved to a `State` operation was lowered as a call to some other declaration. Four routes, all check-green — an imported module's **private** `get`, a **public** one a selective import excludes, a `where` helper of a **`forall` parent** (which keeps a bare key beside its clone-qualified one where a non-generic parent's does not), and the ability operation `show`, which E151 does not reserve and which reaches the same table through the INTRINSIC gate rather than the op one. Where the widths agreed the module loaded and answered the invisible declaration's value (7007 for the cell's 42007); where they differed it failed to load; the generic-`where` route is always loud (`unknown func $get`). Every expected value is the checker's, PROVEN by a type oracle rather than assumed — the invisible `get` returns `@Bool` while the caller returns `@Int` from it and checks green — and each route carries a rename control. The visibility matrix (public/private × in-filter/excluded/wildcard × shadowed/unshadowed × direct/transitive) asserts the verify verdict and the runtime value together per cell. The two directions are pinned at once: the sibling loses the name, the generic TEMPLATE keeps it (asserted on the emitted instruction stream, since monomorphization supersedes the template and a value assertion would be green either way), and a lifted closure — compiled through its own `WasmContext` — inherits its parent's scope. Four table invariants sit beside them: the scoped set is a subset of the registry, every `$`-bearing key stays in it, prelude names stay in it, and every emission door supplies a declaration its own helpers | | `test_phantom_generic_instances_1271.py` | 14 | 358 | #1271 — discovery inside a still-generic scope must not instantiate a callee at an ENCLOSING scope's type VARIABLE. `pick(@U.1, @U.0)` inside `forall fn helper` bound `pick`'s variable to the NAME `U`, so a `pick$U` clone was emitted whose parameter has no WASM type and which the compilability pass then skipped with a loud `[E604]` — the noise that kept #1223's shapes out of the conformance suite. Drives the four #1223 fixtures plus a mutual-recursion shape (two sibling generic helpers under a generic parent, whose phantoms include one arriving through a callee's declared RETURN type, `leaf$W`), asserting on ONE compile that no clone is keyed by a type variable, that no E602/E604/E605 skip is emitted, AND that the genuinely concrete clone is still there — the third assertion being what separates the filter from an over-filter that would take the real instantiation with it. Plus the **primitive-spelled binder** matrix (`forall`, ``, ``, ``), each row instantiating a sibling at exactly the type its binder is spelled like — a shared `idw(5)` would have let every row but `Int` pass for free — asserted on the clone set AND on the program still running; with the `Q`-binder control that keeps a genuine type variable filtered, so the fix cannot degenerate into "never filter". That control CREATES a live phantom candidate — a generic helper under a generic parent, handing its callee an argument typed by its own binder — because a control that merely fails to create one holds under any filter including none; mutation-checked by disabling the filter, which turns it red | | `test_handle_exn_divergent_result_1276.py` | 10 | 391 | #1276 — a `handle[Exn]` whose clause body AND handled body both diverge emitted a result-LESS `block` into a result-expecting context: check-green, verify-green, rejected at load with `type mismatch: expected i64 but nothing on stack`. Four divergent shapes (the issue's Int rethrow, the `Byte` payload spelling #1269 unmasked, a three-deep rethrow chain, and a clause diverging through both arms of an `if`), each asserted on valid WASM AND on the observable — the OUTER handler's clause value, 1000. Paired with the Unit TWIN, which infers `None` for the same reason but DOES complete: it must keep running and its WAT must contain no `unreachable` at all. The pairing is the point — `result_wt is None` means two things wanting opposite lowerings, and a fix that terminated both would trap a program that runs. The MIRROR family covers a clause that throws on one path and COMPLETES on the other (`if` and `match` spellings), where the inference read only the `then` branch / arm 0, answered `None`, and left the completing path's value stranded in a result-less block; the `if` case appears twice with different thrown values so both the throwing and the completing path are exercised from one inference | @@ -201,7 +201,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_markdown.py` | 94 | 610 | Markdown parser: block/inline parsing, rendering, round-trips, edge cases | | `test_lsp.py` | 146 | 2470 | LSP transport + coordinate layer (#222 Phase C) and language features (#222 Phase D): parametrized code-point↔UTF-16 goldens incl. astral-plane fixtures and surrogate-pair snapping, Span (1-based, exclusive-end) and SourceLocation (0-based col) → LSP Range conversions, point→token-range widening, DocumentStore open/change/close + index invalidation, an in-process handler-drive test, and one stdio end-to-end round-trip against the real `vera lsp` subprocess (initialize → didOpen → shutdown → exit) pinning serverInfo + textDocumentSync capabilities; plus the Phase D feature suite — parse-error single-diagnostic path, type-error verification short-circuit, tier=3 in E520 diagnostic data, per-function tier Hint synthesis (and its suppression for functions with violated obligations), smallest-enclosing-span hover, De Bruijn slot goto (most-recent-parameter jump, out-of-range None, off-slot None, and the #1208 keying pins: a parameterised reference resolves, an alias-spelled parameter is reachable from a canonically-spelled reference, and a `forall` variable shadowing a module alias lands on the right parameter), and typed-hole completion (inside/after hole, away-from-hole None); plus the Phase E speculativeEdit suite — identical-text all-unchanged, breaking edit surfaces newly_undischarged (violated nat_sub) with canonical state untouched, strengthening edit surfaces newly_discharged, parse/type errors report ok:false, deleted functions report removed, proof_delta purity; plus the Phase F1 proposeEdit suite — the apply gate (clean and strengthening edits apply, breaking and non-compiling edits refuse), force overriding both gates with the delta still reported, wiring against a structural fake server (apply round-trip with exact full-document replacement range, refuse touches no canonical state, unopened-URI clamp sentinel), and full-document-range goldens (trailing-newline virtual line, UTF-16 end column); plus the Phase F2 strengthenContract suite — splice goldens (first-clause-only replacement with byte-identical remainder, ensures variant, unknown-fn None), the call-site audit pin (tightened precondition refused with newly_undischarged call_pre items, canonical state untouched), provable-ensures strengthening applies, and the three splice-target refusal paths (no analysis, unparseable document, unknown function); plus the Phase F3 addEffect suite — transitive-caller closure goldens (diamond in declaration order, leaf, unknown-fn None, recursion appears once), handler bounding (#725: a caller discharging the effect around its only call site drops out of the closure and is not rewritten, while a second unhandled path, a call in a handler clause, a handler for another effect, a handler naming a different instance of the same effect (`handle[State]` against a `State` propagation, which the checker does not discharge — end-to-end that caller must still be rewritten and the candidate must still apply, with the matching `State` propagation against the same fixture as the positive control that this handler key does prune something), and a bare `where`-helper call all keep it in, as does a refinement type argument at either depth (`Exn<{ @Int \| p }>` and `Exn>` render as their bare base type but discharge nothing of `Exn` — also pinned end-to-end), while an unparameterised `handle[IO]` does bound an `IO` propagation, a whitespace-spelled `State< Int >` request still bounds, and nesting bounds in either order (a matching handler inside a foreign one, a foreign one inside a matching one) — plus a key-level pin that `handle[Mod.IO]` keeps its module, and the effect-less query pinned handler-unaware; plus the two boundary pins the review added — a call in the handler's STATE INITIALISER keeps its edge, since the initialiser is evaluated in the enclosing scope before the handler is installed (pinned beside the E125 the checker raises there against a `pure` caller, with the identical call in the handler body clean as the contrast), and an alias-spelled handler does not bound a `State` propagation though the checker discharges it (`handle[State]` with `type MyAlias = Int` — the spelling comparison's under-prune, #1292, with the alias-spelled request as the control that does prune)), effect-row rewrite goldens (pure to singleton set, source-preserving append, already-present None, base-name identity blocking State next to State), diamond propagation applying one multi-site candidate with the bystander untouched, mixed append/replace rows with already-satisfied callers skipped, the fully-satisfied no-op shape, and the two refusal paths; plus the #728 instruction-contract suite — the LSP message carries description, rationale, and the Fix: paragraph (also pinning single E501 emission at the LSP surface), and a bare diagnostic maps to the description alone | | `test_browser.py` | 347 | 4,733 | Browser parity: Python/wasmtime vs Node.js/JS-runtime output equivalence across IO, State, contracts, Markdown, Regex, and the examples the browser target can execute (two explicit lists in the file, not the whole `examples/` directory — interactive stdin, file IO, `DB` and the non-standalone `modules` example are excluded with their reasons recorded); plus the #349 `runtime.mjs` coverage battery — per-value-type and per-key-type Map variants, per-element-type Set variants, cold `Decimal` branches (exact-zero sign, negative-shift division, `decRoundPlaces` special cases, non-finite storage), `readJson`/`json_stringify` across every Json ADT tag, the Regex/Json `Result.Err` arms, and nested-Markdown walks. Two operations carry a canonical form the specification states rather than merely agreeing across the hosts, so their batteries assert more than equality: `json_stringify` (spec §9.7.1) pins the expected string on every Json ADT tag and on the number-rendering boundaries, checks three-pass idempotence, checks that a non-finite `JNumber` fails on both hosts *and* prints nothing, pins the object key orders an ordinary JS object cannot carry (array-index keys, which ES enumeration hoists to the front in ascending numeric order, and a `__proto__` key, whose assignment writes a prototype instead of a field) on both a parsed and a program-built object, and checks the reference host's own number rendering differentially against a real `JSON.stringify` over doubles drawn from raw bit patterns; `md_render` (§9.7.3) pins the expected render, re-renders it to prove the fixed point, runs the round-trip property over a corpus carrying the container and multi-line shapes a flat corpus misses, and renders `MdBlock` values the test builds directly, since several renderer rules — a container's child separator, an empty container, a code span wider than one backtick — are unreachable through `md_parse`. `md_parse` itself is not yet at parity and its remaining divergence classes are a tracked bug ([#1301](https://github.com/aallan/vera/issues/1301)); the suite pins the shapes the two implementations agree on | -| `test_conformance.py` | 1145 | 144 | Parametrized conformance suite: parse, check, verify, run, format idempotency across 219 programs; a negative entry fails at the stage `expected_error_stage` names (`check`, the default, or `compile` — which also asserts the program type-checks cleanly first) | +| `test_conformance.py` | 1145 | 144 | Parametrized conformance suite: parse, check, verify, run, format idempotency across 229 programs; a negative entry fails at the stage `expected_error_stage` names (`check`, the default, or `compile` — which also asserts the program type-checks cleanly first) | | `test_prelude.py` | 29 | 585 | Prelude injection: Option/Result/array operation detection, combinator shadowing, type aliases, the reserved namespace every injected alias declaration lives in — checked against the checker's own E154 regex rather than a second spelling of the rule, since an alias the prelude injects outside it is one codegen resolves and the checker leaves opaque (#1184/#1221) — end-to-end compilation | | `test_checker_apply_fn.py` | 18 | 455 | #854 — `apply_fn` as a checker special form: zero-warning pins (API + CLI `--json` + closures.vera), E201 arity / E202 type / non-function-first-arg errors, E122/E125 effect-row enforcement for applied fn values, E151 redefinition rejection, variadic two-param application, prelude combinator regression pins | | `test_prelude_diagnostics.py` | 8 | 271 | #851 — prelude combinator skip-warnings: unreferenced-prelude E602/E604 suppression (zero-warning minimal compile, API + CLI `--json`), `` origin attribution for referenced-but-skipped combinators (text + `to_dict`), transitive reference scan, and user-fn warning locations pinned unchanged | @@ -417,7 +417,7 @@ The manifest is the machine-readable feature inventory — agents can query it t ### Running the conformance suite ```bash -# Via pytest (parametrized — 1,130 tests) +# Via pytest (parametrized — 1,145 tests: five stages × 229 entries) pytest tests/test_conformance.py -v # Via standalone script (used in CI and pre-commit) From 90ff698536146ecb392f894147f3a6e829b2c97e Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 13:40:23 +0100 Subject: [PATCH 31/69] Say what the two skipped-tests tables actually split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The total was right and the taxonomy was not. All 102 conformance-stage skips come from ONE mechanism, the level rule — a program declared at `check` skips `verify` and `run`, one declared at `verify` skips `run`, which is 41 × 2 + 20 and exactly what the suite reports. The four "environment-gated" rows are not a second mechanism and not additional: `ch09_http` and `ch09_inference` are declared at `check` level, so their skips arrive as `check-only` like every other check-level program, and the live run reports only those two reasons. What the second table adds is the REASON those two sit at `check` — an environment CI does not have, rather than the feature under test. So the sentence now states both totals and what separates them: 98 pinned by the feature, 4 by the environment, each skip listed once, the tables disjoint (verified: no program appears in both, and 98 + 4 matches the live 102). Reading "two categories" as two mechanisms was the trap — it invites adding the four to the total, which would make it 106. Skip-changelog: documentation only, no behaviour change. Co-Authored-By: Claude --- TESTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TESTING.md b/TESTING.md index bbf08439..df7bbbf9 100644 --- a/TESTING.md +++ b/TESTING.md @@ -259,7 +259,7 @@ Almost all programs are at the `run` level — they compile and execute, produci ### Skipped tests -`pytest tests/ -v` skips 102 conformance-stage tests across the two categories below (the suite's remaining skips are platform- or tool-gated and documented beside the tests that declare them): +`pytest tests/ -v` skips 102 conformance-stage tests, and every one of them is the level rule: a program declared at `check` skips its `verify` and `run` stages, one declared at `verify` skips its `run` — 41 × 2 + 20, which is what the suite reports. The two tables below split those 102 by why the program sits at its level, not by how it skipped: 98 are pinned there by the feature under test, and 4 by an environment CI does not have. Each skip is listed once; the tables do not overlap. (The suite's remaining skips are platform- or tool-gated and documented beside the tests that declare them.) **Level-limited skips** — the conformance framework only runs tests up to the declared level; stages beyond that level are automatically skipped. These are expected and correct. From 0b65fb0202db0d3ed72279412071490a8792648b Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 11:53:44 +0100 Subject: [PATCH 32/69] Run the examples in CI, not only check, verify and compile them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An audit of every site that touches examples/ found seventeen of the 42 executed by nothing at all: array_utilities, async_http_fanout, collections, database, fizzbuzz, html, http, inference, io_operations, json, life, maximum_syntax, modules, nested_closures, read_char, scoreboard and string_utilities. file_io makes eighteen — it ran only under the browser runtime, where the file IO it demonstrates is a deliberate Err stub. Between them they cover Map/Set, the DB effect, JSON and HTML parsing, module imports and the whole string-utility family, so a runtime regression in any of it could reach a release with every gate green. check_examples.py type-checks and verifies all 42 and check_e602_clean.py compiles all 42 as a side effect of policing silent translator skips; none of that executes one. scripts/check_examples_run.py runs 34 of the 42 under the native runtime and asserts a trap-free exit. The other 8 carry a documented skip property -- network, api-key, stdin, non-scalar-entry, long-running -- which the report prints with its reason on every run. Output pinning stays in the dedicated tests that already do it, so the gate does not go red on a cosmetic edit to an example. The load-bearing part is the coverage rule rather than the runs. The script enumerates examples/*.vera from disk and requires every name to sit in exactly one of its two tables, so an unclassified example fails the gate and adding one forces the author to decide whether the harness can drive it; a table key whose file is gone fails it too, so a suppression cannot outlive its example. TESTING.md gains the execution model in full, cross-checked against the script's own classification on the check_doc_counts model, and its round-trip section is corrected -- it claimed all 42 examples were tested through every pipeline stage including compilation and execution, where the directory-globbing parametrised tests in fact stop at verification and canonical form. Runs are hermetic: an ambient VERA_DB_URL or provider key is stripped, so a gate run cannot be pointed at a real database or turned into a billed API request, and each example gets a scratch working directory so file_io.vera stops dropping hello.txt beside the sources. Co-Authored-By: Claude --- .github/workflows/ci.yml | 3 + .pre-commit-config.yaml | 14 + CHANGELOG.md | 4 + CLAUDE.md | 1 + CONTRIBUTING.md | 2 +- TESTING.md | 89 +++++- scripts/check_examples_run.py | 521 +++++++++++++++++++++++++++++++ tests/test_check_examples_run.py | 505 ++++++++++++++++++++++++++++++ 8 files changed, 1134 insertions(+), 5 deletions(-) create mode 100644 scripts/check_examples_run.py create mode 100644 tests/test_check_examples_run.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfaa669e..ffe9d265 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -177,6 +177,9 @@ jobs: - name: Check no unexpected [E602]/[E604] silent skips (Layer 1 of #626) run: python scripts/check_e602_clean.py + - name: Check every example runs trap-free or carries a skip property + run: python scripts/check_examples_run.py + - name: Check editor grammars and READMEs carry every built-in effect (#1156) run: python scripts/check_editor_grammars.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ea3e324e..bd5a781e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -181,6 +181,20 @@ repos: pass_filenames: false files: '(examples/.*\.vera$|tests/conformance/.*\.vera$|vera/.*\.py$|vera/grammar\.lark$|vera/prelude\.py$|scripts/check_e602_clean\.py$)' + # The examples RUN. check_examples.py checks + verifies them and + # the e602 gate above compiles them; none of that executes one, so + # an example could trap at run time and stay green. The gate runs + # every example it can drive and requires the rest to carry a + # documented skip property, so a new example cannot be added + # without classifying it. Also cross-checks TESTING.md's + # execution-model table against the script's own classification. + - id: examples-run + name: examples run trap-free + entry: .venv/bin/python scripts/check_examples_run.py + language: system + pass_filenames: false + files: '(examples/.*\.vera$|examples/sqlitedb\.sqlite$|vera/.*\.py$|vera/grammar\.lark$|TESTING\.md$|scripts/check_examples_run\.py$)' + - id: doc-counts name: doc counts entry: .venv/bin/python scripts/check_doc_counts.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d144f1d3..25802a7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- **The examples are now RUN in CI, not only checked, verified and compiled** (`scripts/check_examples_run.py`). `check_examples.py` type-checks and verifies all 42, and `check_e602_clean.py` compiles all 42 as a side effect of policing silent translator skips — but nothing executed them as a set, and an audit of every referencing test found **seventeen examples that no test ran at all**: `array_utilities`, `async_http_fanout`, `collections`, `database`, `fizzbuzz`, `html`, `http`, `inference`, `io_operations`, `json`, `life`, `maximum_syntax`, `modules`, `nested_closures`, `read_char`, `scoreboard` and `string_utilities`, plus `file_io`, which ran only under the browser runtime, where the file IO it demonstrates is a deliberate `Err` stub. Between them they demonstrate `Map`/`Set`, the `` effect, JSON and HTML parsing, module imports and the whole string-utility family, so a runtime regression in any of it could reach a release with every gate green. The gate now runs 34 of the 42 under the native runtime and asserts a trap-free exit; the other 8 carry a documented skip property (`network`, `api-key`, `stdin`, `non-scalar-entry`, `long-running`) which the report prints with its reason on every run. Output pinning deliberately stays in the dedicated tests that already do it, so the gate does not go red on a cosmetic edit to an example. The load-bearing part is not the runs but the **coverage rule**: the script enumerates `examples/*.vera` from disk and requires every name to be in exactly one of its two tables, so an unclassified example is an error and adding one forces the author to decide whether the harness can drive it — and a table key whose file is gone is an error too, so a suppression cannot outlive its example and mask a later program of the same name. The classification is cross-checked against a new execution-coverage table in `TESTING.md` on the `check_doc_counts.py` model, the codebase being the oracle and the documentation having to match it, so the execution model stops living in maintainers' heads. Runs are hermetic: an ambient `VERA_DB_URL` or inference-provider key is stripped from the environment, so a gate run cannot be pointed at a real database or turned into a billed API request, and each example gets a scratch working directory so `file_io.vera` stops dropping `hello.txt` beside the sources. `TESTING.md`'s round-trip section is corrected with them — it claimed all 42 examples were tested through "every pipeline stage ... WASM compilation, and execution", where the directory-globbing parametrised tests in fact stop at verification and canonical form. + ### Fixed - **A user-defined `fn get` / `fn put` is no longer hijacked by an enclosing handler** ([#1284](https://github.com/aallan/vera/issues/1284)). Three sites answered "does this `get` mean the user's declaration or the effect operation?" independently. The checker answers user-fn-first — `_check_call_with_args` looks a bare name up as a function before it looks it up as an operation, so a declaration named `get` owns every bare `get(...)` in its scope, which an arity or argument-type error at such a call site proves by reporting the *user's* signature (E201/E202). Codegen answered twice more: the declared-effect row in `vera/codegen/functions.py` withheld the intrinsic when `_fn_sigs` already owned the name, and the handler expression in `vera/wasm/calls_handlers.py` installed `get`/`put` unconditionally. From `vera check`-green source that produced, depending on the nesting shape, a **silently wrong value** (`nat_to_int(get(3))` under `handle[State](@Int = 5)` returned the cell's 5 for the function's 4, and the argument was not even emitted), a **module WASM validation rejects** (a `@Bool`-returning user `get` took `state_get_Int`'s i64 into an `i32` position; different-family nesting took the *enclosing* cell's getter at the wrong width), or a **spurious `[E602]`** in which the #1233 unaddressable-cell gate refused `main` outright, naming "a bare or qualified State operation `get`" the program never contained. The repair is one predicate, `vera.slots.bare_call_denotes_user_fn`, stating the checker's rule once and consumed by the bare-call dispatch in `vera/wasm/calls.py` (which now gates the clause-inline registry, the host-cell intrinsics and the addressability gate together), by the three bare-`FnCall` result-type inference sites, and by the monomorphizer's discovery walk — each passing its own name table, so the sites cannot answer differently about the table they share. Gating the *dispatch* rather than the *registries* is what makes it correct rather than merely consistent: the registries record which cell an op name reaches, which is true whatever the program's declarations are called, and withholding an entry answered both questions with one table. That is why the gate-only fix measured during PR #1283's review turned the loud skip into a differently-broken module, and why it also cost the qualified spelling its cell — `State.put(5)` in a function that also declares `fn put` compiled to `call $vera.put` and failed to link, which now lowers to the intrinsic the checker always meant. Discovery's `MonoContext.fn_names` moves to the same lookup-time question, so a `get(())` fixing a generic's type variable under a handler names the clone the rewrite emits. All 256 conformance and example programs emit byte-identical WAT. **The `W002` async-commutativity warning was the same defect in the checker's own file** and is corrected with them: `_collect_expr_effects` asked `lookup_effect_op` before the scoped function lookup — the last op-first consumer — so a user function named after an operation contributed the *operation's* parent effect to the commutativity analysis instead of its own declared row, wrong in both directions. A **pure** `fn get`, in a program containing no `State` at all, drew `async argument performs State effects`; a `fn get` that performs `IO`, under a row naming `Http` first, drew **no warning**, because the walk bound the name to `Http.get`, which is inside the commutative whitelist, and silently withheld the eager-evaluation warning the program is owed. Both are pinned with rename controls — the byte-identical program with the helper called `gett` / `fetch` was correct throughout — beside a control that an unshadowed bare `get(())` under a `State` row still warns, so the fix cannot degenerate into never reporting `State`. The walk's comment claiming it resolves "like the call checker above" is now true. One caveat the predicate did not close on its own: codegen's name table was not scope-accurate, so a name the call site cannot see still answered "user-owned" there — a property of the table rather than of the rule, closed by [#1299](https://github.com/aallan/vera/issues/1299) below. diff --git a/CLAUDE.md b/CLAUDE.md index 848c4ad7..20b085e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,6 +60,7 @@ mypy vera/ # Type-check the compiler itself python scripts/check_conformance.py # Verify all 229 conformance programs (positives pass their level; negatives fail with their expected_error E-code) python scripts/check_examples.py # Verify all 42 examples parse + check + verify +python scripts/check_examples_run.py # Run every example trap-free under the native runtime; an example that is neither run nor matched to a documented skip property is an error python scripts/check_corpus_canonical.py # Verify all 278 corpus programs are in canonical form (vera fmt) python scripts/check_examples_readme.py # Verify vera run commands in examples/README.md python scripts/check_spec_examples.py # Verify spec code blocks parse diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 02199f24..b60e7fa6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -89,7 +89,7 @@ dependencies. CI enforces that `uv.lock` stays current. ### Pre-commit Hooks -The repository configures 35 hooks across two stages: 33 run at the commit stage (after `pre-commit install`), and 2 (`check-changelog-updated` and `uv-lock-check`, described below) run at the push stage (after `pre-commit install --hook-type pre-push`). Most commit-stage hooks have per-hook `files:` / `types:` filters — the `python` type-check only runs when Python files are staged; `check_readme_examples.py` only runs when `README.md` or Vera sources change, etc. A plain-text commit touching only one markdown file triggers a small subset; a compiler-level commit triggers most of them. +The repository configures 36 hooks across two stages: 34 run at the commit stage (after `pre-commit install`), and 2 (`check-changelog-updated` and `uv-lock-check`, described below) run at the push stage (after `pre-commit install --hook-type pre-push`). Most commit-stage hooks have per-hook `files:` / `types:` filters — the `python` type-check only runs when Python files are staged; `check_readme_examples.py` only runs when `README.md` or Vera sources change, etc. A plain-text commit touching only one markdown file triggers a small subset; a compiler-level commit triggers most of them. ![The gate pipeline: file-filtered commit-stage hooks, the push-stage CHANGELOG and uv.lock gates, and CI re-running everything against the platform matrix before anything lands on protected main.](assets/diagrams/ci-gates.svg) diff --git a/TESTING.md b/TESTING.md index df7bbbf9..a3635314 100644 --- a/TESTING.md +++ b/TESTING.md @@ -215,6 +215,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_release.py` | 47 | 566 | Release policy and registry verification (#481): strict project-name and version parsing/comparison, version-bump/TestPyPI/recovery planning, exact confirmation and immutable-tag guards, first-parent version-introduction discovery, package-change recovery refusal, non-empty CHANGELOG extraction, one-wheel/one-sdist SHA-256 manifests, malformed registry-response handling, missing/filename/hash propagation retries, exact filename/hash verification, and CLI dispatch/GitHub-output wiring. An autouse fixture scrubs hook-exported `GIT_*` variables so the tmp-repo git calls (fixture helpers and `release.py`'s own) never resolve to the developer's repository when the suite runs inside a pre-commit hook. | | `test_check_doc_counts.py` | 54 | 665 | `check_doc_counts.py`'s pure per-document checks: KNOWN_ISSUES refactoring line counts (±10% tolerance band incl. the exact-boundary case, drift detection, empty-file citation, hyphenated paths, missing file/section/rows, the #419 empty-section sentinel + its cannot-mask-a-malformed-table dual), HISTORY version-row format (issue-link limit, ` — ` separator rejection, dateless-row and prose exemption, line-number reporting), the TESTING.md tests breakdown (parts summing to the collected total, a self-consistent-but-stale row, and the reworded-row error), and vera/README.md's Test Suite counts (all four checked independently — mutation-validated by dropping each citation in turn — plus the reworded-paragraph error, a thousands-separator case pinning that every one of the four counts is read comma-tolerantly, and the two section-anchoring cases — a reworded paragraph with decoy counts in a later section, and a renamed heading, both of which must fail loud rather than match across the section boundary). The reworded case is a test in its own right for both new checks: a pattern that matches nothing must be an error, or rewording the sentence silently switches the gate off. Also the release count (README's status line and HISTORY's total against each other and against `git tag`: the matching case, the one-ahead release cut that `release.yml` has not tagged yet, that +1 being the ONLY slack once the version is tagged, the two-behind drift that actually shipped, per-document reporting, and a tagless checkout standing the oracle down without standing down the cross-check), plus the tag reader itself against real repositories built in `tmp_path` — release tags read, `nightly`/`-rc1` not counted as releases, and both no-evidence answers (`None` rather than `[]`, since an empty list would read as zero releases and make every documented count wrong) for a tagless checkout and a directory that is not a repository at all. CONTRIBUTING.md's pre-commit hook count is checked the same way, reworded-sentence case included; and the CI-pipeline lint row against `ci.yml`'s lint job — a matching row, a step present in CI but absent from the row (the drift that shipped), a row entry CI no longer runs, the same set in a different order, a reworded row and a renamed job (both errors, not skips), that only the lint job is read rather than the whole workflow, and the shipped pair both clean and red with one entry dropped | | `test_grammar_alignment.py` | 44 | 309 | `check_grammar_alignment.py` gate ([#683](https://github.com/aallan/vera/issues/683)): both extractors (Lark headers with the `?`/`!`/`_` markers stripped, template parameters and rule priorities tolerated, and `-> alias` names deliberately not collected; spec headers from ```ebnf fences only), the allowlist arithmetic in all three directions — unwaived drift, a spent entry both files now have, and an entry whose premise broke — one case per waiver proving the fact it rests on is actually checked, the name-deleted-from-both-files case that must not read as agreement, a mutation restoring the spec's old `assert_stmt` name, non-vacuous extraction, and the false positive the issue itself rested on: `qualified_call` and `module_call` are spec headers Lark expresses as aliases, and must never be reported as drift. Three pin the premise checks against ways they used to pass vacuously: an alias surviving only inside a `//` comment must not hold its waiver up, an alias that moved to another rule must fail the waiver naming `fn_call`, and a spent waiver whose premise also broke must yield one instruction rather than two opposite ones | +| `test_check_examples_run.py` | 38 | 505 | `check_examples_run.py`, the harness gate that RUNS the examples. Three separable parts, each in both directions. **The coverage rule** — the shipped tables cover the shipped corpus exactly, and an unclassified example, a stale `RUN_SPECS` or `SKIPS` key whose file is gone, a name in both tables, and a skip citing an undocumented property are each an error; the empty corpus is an error too, since a glob that stops matching would otherwise report success over zero programs. Plus the specs' own well-formedness: every named entry point is `public` in its example, every no-main example pins one (or `vera run` would fall back to an arbitrary first export), and no skip property is unused. **The runner** — a seeded `tmp_path` corpus proves it goes red on a program that type-checks and compiles but traps at run time, green on one that does not, and reports only the broken member of a mixed pair; a fixture whose first export is clean and whose named one traps proves `spec.fn` is actually honoured rather than ignored; a writer program proves each run gets a scratch working directory, so a gate run leaves nothing beside the examples. **The TESTING.md cross-check** — missing row, extra row, rename (reported naming both sides), wrong disposition and wrong skip property are errors, the parse stops at the next heading so a row-shaped line in a later section is not swept in, and both a reworded heading and a heading whose table has vanished fail loud rather than finding nothing to compare. Also the hermetic-environment property: an ambient `VERA_DB_URL` or provider key is stripped so a gate run cannot be pointed at a real database or turned into a billed API call, a fixture spec puts its own URL back, and every neutralised name is checked to be one `vera/runtime/` actually reads | | `test_check_editor_grammars.py` | 20 | 249 | `check_editor_grammars.py` gate ([#1156](https://github.com/aallan/vera/issues/1156)): the registry read (every effect in, every ability out, and the four names the grammars actually drifted on present so the set checked is non-vacuous), the word-boundary presence test across all three grammar formats (JSON, plist XML, Vim keyword list) including the prefix pair `Http`/`HttpServer` in both directions, the deliberate comment-mention false pass, a metacharacter pair that only passes when the name is matched literally (`A.B` present, `A0B` not), and the empty registry; the gate's primary path end to end — a listed grammar with an effect stripped out, and a listed README with one stripped out of its prose bullet, each red against an otherwise-clean mirrored tree; the completeness guard — a grammar discovered under `editors/` but absent from `GRAMMARS` fails that same tree, over three discovery routes (`.el` outside a syntax directory, a tree-sitter `.scm` query set, a `.tmLanguage.json` filed anywhere but `syntaxes/`); and the registry's provenance, run as a subprocess against a throwaway checkout whose `vera` package names an effect the grammars do not, which is the only way to see that the list comes from the tree being checked rather than from site-packages. The shipped grammars and READMEs are currently clean | | `test_check_explicit_encoding.py` | 54 | 254 | `check_explicit_encoding.py` gate (#645): flags text-mode `open()` / `read_text()` / `write_text()` **and** `subprocess.run/Popen/check_output(..., text=True)` captures missing an `encoding="utf-8"` literal (rejects non-literal / non-UTF-8 values), skips binary/bytes-mode calls, honours the `# encoding-exempt` opt-out, and asserts the shipped repo is clean | | `test_check_limitations_sync.py` | 6 | 108 | `check_limitations_sync.py` section extraction: table-rows-only issue harvesting, prose-link exemption, bounding at the next second-level heading, `None` for absent or sub-level headings so renamed sections fail loudly; plus the #852 fail-loud rule: an UNKNOWN issue state under `--check-states` (gh missing / auth failure / timeout) is an error, never a silent pass | @@ -610,10 +611,88 @@ use `_assert_call_indirect_iff_table` for the biconditional. ## Round-Trip Testing -Every one of the 42 example programs in `examples/` is tested through **every pipeline stage** via parametrised tests: parsing, AST transformation, type checking, contract verification, WASM compilation, and execution. If you add a new `.vera` example, it is automatically included in the round-trip suite. +Every one of the 42 example programs in `examples/` is carried through the front of the pipeline by parametrised tests that glob the directory, so a new `.vera` example joins them the moment it lands: parsing (`test_parser.py`), AST transformation (`test_ast.py`), type checking (`test_checker_functions.py`), contract verification (`test_verifier_contracts.py`), and canonical form (`test_formatter.py`). + +The back of the pipeline — compilation and execution — is not covered by a directory glob, and is described in full below. The formatter has **idempotency tests**: `format(format(x)) == format(x)` for all tested programs. +### Example execution coverage + +An example that parses, type-checks, verifies and compiles can still trap the instant it runs. Five layers cover `examples/`, and only the last two execute anything: + +| Layer | Mechanism | Reach | +|-------|-----------|-------| +| Check + verify | `scripts/check_examples.py` | all 42 | +| Canonical form, parse, transform, check, verify | the directory-globbing parametrised tests above | all 42 | +| Compilation to WASM | `scripts/check_e602_clean.py` — it exists to police `[E602]`/`[E604]` silent skips, but it compiles every example with `--json` and treats an `ok: false` envelope as a hard failure, so full compile coverage is real though incidental to the script's name | all 42 | +| Execution under both runtimes | `tests/test_browser.py`, from two explicit lists — `EXAMPLES_WITH_MAIN` (10, compared on stdout) and `FUNCTION_CALL_EXAMPLES` (11 distinct examples, compared on return value) | 21 | +| Execution with pinned output | dedicated tests, each asserting a specific value or rendering (see the table) | 12 | +| Execution asserted trap-free | `scripts/check_examples_run.py` — the harness gate | 34 | + +The gate is what makes the set closed. It enumerates `examples/*.vera` from disk and requires every name to be either run or matched to a documented skip property, so **an unclassified example fails the gate** and adding an example forces the author to classify it. The table below is cross-checked against the script's own tables on every run: a row that disagrees, a missing row, or a renamed example is an error, on the same principle as `check_doc_counts.py` — the codebase is the oracle and the documentation must match it. + +What the gate asserts is *runs green*, deliberately not *prints what it used to*: output pinning stays in the dedicated tests, which is why `sqlitedb.vera`'s rendered city table and `inference_json.vera`'s score line are pinned there and only trap-freedom here. + +| Example | Executed by | Harness gate | +|---------|-------------|--------------| +| `absolute_value.vera` | browser parity (return value); `test_codegen_infrastructure.py` pins three results | runs | +| `array_utilities.vera` | nothing, before the gate | runs | +| `async_futures.vera` | browser parity (stdout) | runs | +| `async_http_fanout.vera` | nothing | skip: network | +| `base64.vera` | browser parity (stdout) | runs | +| `closures.vera` | browser parity (return value); `test_codegen_closures.py` pins 15 and 105 | runs | +| `collections.vera` | nothing, before the gate | runs | +| `database.vera` | nothing, before the gate | runs | +| `effect_handler.vera` | browser parity (stdout + State round-trips); `test_codegen_effects.py` pins six results | runs | +| `factorial.vera` | browser parity (return value); `test_codegen_infrastructure.py` pins 120 | runs | +| `file_io.vera` | browser runtime only, where file IO is a documented `Err` stub; never run natively before the gate | runs | +| `fizzbuzz.vera` | nothing, before the gate | runs | +| `gc_pressure.vera` | browser parity (stdout) | runs | +| `generics.vera` | browser parity (return value); `test_codegen_monomorphize.py` compiles it without running it | runs | +| `hello_world.vera` | browser parity (stdout); `test_codegen_strings.py` pins the greeting | runs | +| `html.vera` | nothing, before the gate | runs | +| `http.vera` | nothing | skip: network | +| `http_server.vera` | `test_wasi_target.py` serves the emitted component under stock `wasmtime serve` and pins three request round-trips | skip: non-scalar-entry | +| `increment.vera` | browser parity (return value + State); `test_codegen_effects.py` | runs | +| `inference.vera` | nothing | skip: api-key | +| `inference_json.vera` | `test_codegen_host_effects.py` pins five score renderings and the bad-response arm against a mocked provider | skip: api-key | +| `io_operations.vera` | nothing | skip: stdin | +| `json.vera` | nothing, before the gate | runs | +| `life.vera` | nothing | skip: long-running | +| `list_ops.vera` | browser parity (return value); `test_codegen_monomorphize.py` pins 60 | runs | +| `markdown.vera` | browser parity (stdout) | runs | +| `maximum_syntax.vera` | nothing, before the gate | runs | +| `modules.vera` | nothing, before the gate | runs | +| `mutual_recursion.vera` | browser parity (return value); `test_codegen_infrastructure.py` pins three results | runs | +| `nested_closures.vera` | nothing, before the gate | runs | +| `pattern_matching.vera` | browser parity (return value) | runs | +| `quantifiers.vera` | browser parity (return value) | runs | +| `read_char.vera` | nothing | skip: stdin | +| `refinement_types.vera` | browser parity (return value) | runs | +| `regex.vera` | browser parity (stdout) | runs | +| `safe_divide.vera` | browser parity (return value + precondition failure); `test_codegen_infrastructure.py` pins the result and the trap | runs | +| `scoreboard.vera` | nothing, before the gate | runs | +| `sqlitedb.vera` | `test_db_runtime.py` pins the rendered city table against the committed fixture | runs | +| `string_ops.vera` | browser parity (stdout) | runs | +| `string_utilities.vera` | nothing, before the gate | runs | +| `url_encoding.vera` | browser parity (stdout) | runs | +| `url_parsing.vera` | browser parity (stdout) | runs | + +Twelve examples in that table were executed by nothing at all before the gate, and one — `file_io.vera` — was executed only under the browser runtime, where the file IO it demonstrates is a deliberate `Err` stub. + +Each skip cites a property, and the gate prints the property and its reason on every run: + +| Property | Examples | Why the harness cannot run it | +|----------|----------|-------------------------------| +| `network` | `async_http_fanout.vera`, `http.vera` | live outbound HTTP, so a run would depend on network reachability and a third party's uptime | +| `api-key` | `inference.vera`, `inference_json.vera` | with a provider key configured the gate would issue a real, billed request; without one it would only exercise the not-configured arm | +| `stdin` | `io_operations.vera`, `read_char.vera` | reads interactive input, so what runs is a property of the invoking terminal | +| `non-scalar-entry` | `http_server.vera` | no `main`, and `handle` takes a `Request` ADT that `vera run` cannot build from CLI arguments | +| `long-running` | `life.vera` | its only public entry point animates 300 generations at 100 ms a frame | + +Skipping is for programs the harness structurally cannot drive. An example that *can* be driven and fails is a bug in the compiler or in the example, not a candidate for the skip table. + ## Stress Tests Scale-dependent regression tests live in `tests/test_stress.py` (#596). These exercise Vera programs at sizes where historical bugs (#570 iterative-builder shadow-stack overflow at ~4000 elements, #515 GC self-fault under sustained allocation, #593 Conway's Life corruption at 12×30+) first manifested, plus 2-3x safety margin. @@ -862,7 +941,7 @@ When extending the compiler, add tests following the existing patterns: ## Validation Scripts -Twenty-eight scripts in `scripts/` validate cross-cutting concerns beyond unit tests (one of them — `build_site.py` — generates rather than checks; the doc-block gates share the fence-annotation reader `scripts/doc_annotations.py`, a helper module rather than a gate): +Twenty-nine scripts in `scripts/` validate cross-cutting concerns beyond unit tests (one of them — `build_site.py` — generates rather than checks; the doc-block gates share the fence-annotation reader `scripts/doc_annotations.py`, a helper module rather than a gate): | Script | What it validates | |--------|-------------------| @@ -887,6 +966,7 @@ Twenty-eight scripts in `scripts/` validate cross-cutting concerns beyond unit t | `check_diagnostic_fields.py` | Every diagnostic in `vera/` carries rationale + spec_ref, and errors also a `fix` (warnings exempt); every present spec_ref resolves to a real spec section; every literal `error_code` is registered in `ERROR_CODES` (#828); `# diag-fields-exempt: ` waives missing/unresolvable fields only — never a wrong-but-resolving spec_ref or an unregistered error_code (#682, #955) | | `check_explicit_encoding.py` | Every text-mode `open()` / `read_text()` / `write_text()`, `subprocess.run/Popen/check_output` text capture, and text-mode `tempfile.NamedTemporaryFile` under `vera/`, `scripts/` and `tests/` passes an explicit `encoding="utf-8"`; `# encoding-exempt: ` opts a deliberate non-UTF-8 site out (#645) | | `check_e602_clean.py` | No unexpected E602/E604 silent-skip sites outside the explicit allowlist | +| `check_examples_run.py` | Every `examples/*.vera` either runs trap-free under the native runtime or carries a documented skip property; an unclassified example is an error, and TESTING.md's execution-coverage table must match the script's own classification | | `check_doc_builtin_shadowing.py` | No documentation example defines a function named after an opaque verifier-modelled built-in (would fail `vera check` with E151); the `spec/09` signature reference is exempt ([#819](https://github.com/aallan/vera/issues/819)) | | `check_grammar_alignment.py` | Every rule header in `spec/10-grammar.md`'s EBNF has a same-named rule in `vera/grammar.lark`, and the reverse. Names only — rule bodies are not compared ([#683](https://github.com/aallan/vera/issues/683)) | | `check_editor_grammars.py` | Every editor grammar under `editors/` (vscode, TextMate, Vim), and the two extension READMEs that repeat the list in prose, carries every built-in effect name from the live registry — read from the checked-out tree, not from whatever `vera` is importable. Word-boundary presence: absence is conclusive, presence is optimistic — the observed failure is omission. A completeness guard fails any grammar discovered under `editors/` that the checked list doesn't name ([#1156](https://github.com/aallan/vera/issues/1156)) | @@ -960,7 +1040,7 @@ Per `spec/00-introduction.md` §0.5.8: fields MAY be added (consumers MUST toler ## Pre-commit Hooks -The repository configures 35 hooks across two stages: 33 run at the commit stage (after `pre-commit install`), and 2 (`check-changelog-updated`, `uv-lock-check`) run at the push stage (after `pre-commit install --hook-type pre-push`). Many commit-stage hooks use per-hook `files:` / `types:` filters and only fire when matching files are staged — a docs-only commit triggers a small subset, a compiler-level commit triggers most. Full list: +The repository configures 36 hooks across two stages: 34 run at the commit stage (after `pre-commit install`), and 2 (`check-changelog-updated`, `uv-lock-check`) run at the push stage (after `pre-commit install --hook-type pre-push`). Many commit-stage hooks use per-hook `files:` / `types:` filters and only fire when matching files are staged — a docs-only commit triggers a small subset, a compiler-level commit triggers most. Full list: | Hook | What it does | |------|-------------| @@ -989,6 +1069,7 @@ The repository configures 35 hooks across two stages: 33 run at the commit stage | `check_grammar_alignment.py` | Spec EBNF and Lark grammar agree on every rule name ([#683](https://github.com/aallan/vera/issues/683)) | | `check_editor_grammars.py` | Every editor grammar under `editors/`, and the two extension READMEs, carry every built-in effect name from the live registry ([#1156](https://github.com/aallan/vera/issues/1156)) | | `check_e602_clean.py` | No unexpected `[E602]` (body unsupported) / `[E604]` (param unsupported) silent skips outside the explicit allowlist (Layer 1 of [#626](https://github.com/aallan/vera/issues/626)) | +| `check_examples_run.py` | Every example runs trap-free or carries a documented skip property, and TESTING.md's execution-coverage table matches | | `check_doc_counts.py` | Counts in docs match live codebase | | `check_walker_coverage.py` | Every walker function covers every `Expr` subclass via dispatch or checklist comment (#597) | | `check_diagnostic_fields.py` | Every diagnostic in `vera/` carries rationale + spec_ref, and errors also a `fix` (warnings exempt); every present spec_ref resolves to a real spec section; every literal `error_code` is registered in `ERROR_CODES` (#828); `# diag-fields-exempt: ` waives missing/unresolvable fields only — never a wrong-but-resolving spec_ref or an unregistered error_code (#682, #955) | @@ -1015,7 +1096,7 @@ GitHub Actions ([`.github/workflows/ci.yml`](.github/workflows/ci.yml)) runs the | **test** | Python 3.11, 3.12, 3.13 × ubuntu-latest, macos-15, macos-26, windows-latest, plus advisory ubuntu-24.04-arm × 3.12 (13 combos) | `pytest -v` passes on all combinations | | **test** (coverage) | Python 3.12 x Ubuntu only | `pytest --cov=vera --cov-fail-under=80` | | **typecheck** | Python 3.12 x Ubuntu | `mypy vera/` clean in strict mode | -| **lint** | Python 3.12 x Ubuntu | `check_changelog_updated.py`, `check_conformance.py`, `check_examples.py`, `check_corpus_canonical.py`, `check_examples_readme.py`, `check_version_sync.py`, `check_spec_examples.py`, `check_grammar_alignment.py`, `check_readme_examples.py`, `check_skill_examples.py`, `check_faq_examples.py`, `check_debruijn_examples.py`, `check_pypi_readme_examples.py`, `check_html_examples.py`, `check_doc_builtin_shadowing.py`, `check_e602_clean.py`, `check_editor_grammars.py`, `check_diagnostic_fields.py`, `check_explicit_encoding.py`, `check_site_assets.py`, `check_licenses.py`, `check_doc_counts.py`, `check_limitations_sync.py`, `ruff check .`, `ruff check --select S vera/` (security rules), `uv lock --check` | +| **lint** | Python 3.12 x Ubuntu | `check_changelog_updated.py`, `check_conformance.py`, `check_examples.py`, `check_corpus_canonical.py`, `check_examples_readme.py`, `check_version_sync.py`, `check_spec_examples.py`, `check_grammar_alignment.py`, `check_readme_examples.py`, `check_skill_examples.py`, `check_faq_examples.py`, `check_debruijn_examples.py`, `check_pypi_readme_examples.py`, `check_html_examples.py`, `check_doc_builtin_shadowing.py`, `check_e602_clean.py`, `check_examples_run.py`, `check_editor_grammars.py`, `check_diagnostic_fields.py`, `check_explicit_encoding.py`, `check_site_assets.py`, `check_licenses.py`, `check_doc_counts.py`, `check_limitations_sync.py`, `ruff check .`, `ruff check --select S vera/` (security rules), `uv lock --check` | | **security** | Ubuntu | [Gitleaks](https://github.com/gitleaks/gitleaks-action) secret scanning on full history | | **dependency-audit** | Python 3.12 x Ubuntu | `pip-audit --skip-editable` — checks all installed packages against the OSV vulnerability database (skips the local editable `vera` package) | | **wheel-preflight** | Python 3.12 x Ubuntu | `python scripts/check_wheel_availability.py` — verifies every runtime dep has prebuilt wheels for every (platform, python-version) tuple documented in README §Supported platforms; structural backstop for #691-class install regressions | diff --git a/scripts/check_examples_run.py b/scripts/check_examples_run.py new file mode 100644 index 00000000..716beb55 --- /dev/null +++ b/scripts/check_examples_run.py @@ -0,0 +1,521 @@ +#!/usr/bin/env python +"""Pre-commit / CI gate: every `examples/*.vera` program either RUNS +trap-free under the native runtime, or is matched to a documented +property that excludes it from harness execution. + +`scripts/check_examples.py` type-checks and verifies all 42 examples and +`scripts/check_e602_clean.py` compiles them, so an example that fails to +parse, type-check, verify or compile is caught before this gate. What +none of them does is *run* one. A program can pass every static stage +and still trap the moment it executes — an out-of-bounds index behind a +Tier-3 obligation, a monomorphized clone that resolves to a missing +symbol, a host import nobody bound. Until this gate, the only examples +protected against that were the ones some test happened to execute; the +rest could rot silently between releases. + +The design's load-bearing part is the **coverage rule**, not the runs. +The script enumerates `examples/*.vera` from disk and requires every name +to appear in exactly one of two tables: + +- ``RUN_SPECS`` — how to invoke it (entry point, arguments, fixtures). +- ``SKIPS`` — the property that excludes it, drawn from + ``SKIP_PROPERTIES`` so every suppression carries a stated reason that + the report prints. + +An example in neither is an ERROR, so adding an example forces the author +to decide which it is; a table key with no file on disk is an ERROR too, +so a deleted example cannot leave a suppression behind to mask a later +re-add. The classification is then cross-checked against the table in +TESTING.md (`check_testing_md`), on the `check_doc_counts.py` model: the +codebase is the oracle and the documentation must match it, so the +execution model stops living in maintainers' heads. + +What this gate asserts is *runs green*, deliberately not *prints what it +used to*. Output pinning belongs in the dedicated tests that already do +it — ``tests/test_db_runtime.py::TestDbOnDiskExample229`` pins +`sqlitedb.vera`'s rendered city table, +``tests/test_codegen_host_effects.py`` pins `inference_json.vera`'s +score line against a mocked provider, and ``tests/test_browser.py`` pins +21 examples against the browser runtime. A gate that re-pinned stdout +would duplicate those and go red on every cosmetic edit to an example. +""" + +from __future__ import annotations + +import os +import re +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import NamedTuple + + +# --------------------------------------------------------------------------- +# Classification tables +# --------------------------------------------------------------------------- + + +class RunSpec(NamedTuple): + """How to invoke one example under `vera run`. + + ``fn`` is ``None`` for a program with `main`. For a no-main example + `vera run` falls back to the *first export*, which is generally an + arbitrary function that needs arguments, so those must pin an entry + point explicitly. + + A ``NamedTuple`` rather than a frozen dataclass so the module can be + loaded by the bare ``spec_from_file_location`` / ``exec_module`` + recipe the sibling script tests use: ``@dataclass`` resolves its + annotations through ``sys.modules[cls.__module__]``, which that + recipe leaves unregistered. + """ + + fn: str | None = None + args: tuple[str, ...] = () + needs_db_fixture: bool = False + + +# Every skip property, with the reason it excludes harness execution. +# Printed verbatim in the report, so a reader of a gate run sees what is +# not covered and why without opening this file. +SKIP_PROPERTIES: dict[str, str] = { + "network": ( + "makes live outbound HTTP calls, so a gate run would depend on " + "network reachability and on a third party's uptime" + ), + "api-key": ( + "calls an inference provider; with a key configured in the " + "environment the gate would issue a real, billed API request, and " + "without one it would only ever exercise the not-configured arm" + ), + "stdin": ( + "reads interactive input, so what it exercises is a property of " + "the invoking terminal rather than of the program" + ), + "non-scalar-entry": ( + "exports no `main` and its only entry point takes an ADT " + "parameter, which `vera run` cannot construct from CLI arguments" + ), + "long-running": ( + "its only entry point is a wall-clock animation loop whose " + "duration is fixed by deliberate `IO.sleep` calls" + ), +} + + +# The 34 examples the harness runs. Entry points and arguments follow +# the invocations documented in `examples/README.md`, which +# `scripts/check_examples_readme.py` independently holds to naming +# functions that exist. +RUN_SPECS: dict[str, RunSpec] = { + "absolute_value": RunSpec(fn="absolute_value", args=("-5",)), + "array_utilities": RunSpec(), + "async_futures": RunSpec(), + "base64": RunSpec(), + "closures": RunSpec(fn="test_closure"), + "collections": RunSpec(), + "database": RunSpec(), + "effect_handler": RunSpec(), + "factorial": RunSpec(fn="factorial", args=("10",)), + "file_io": RunSpec(), + "fizzbuzz": RunSpec(), + "gc_pressure": RunSpec(), + "generics": RunSpec(fn="test_generics"), + "hello_world": RunSpec(), + "html": RunSpec(), + "increment": RunSpec(fn="increment"), + "json": RunSpec(), + "list_ops": RunSpec(fn="test_list"), + "markdown": RunSpec(), + "maximum_syntax": RunSpec(), + "modules": RunSpec(fn="clamp_to_range", args=("100", "0", "42")), + "mutual_recursion": RunSpec(fn="is_even", args=("4",)), + "nested_closures": RunSpec(fn="grid_sum"), + "pattern_matching": RunSpec(fn="test_match"), + "quantifiers": RunSpec(fn="test_process"), + "refinement_types": RunSpec(fn="test_refine"), + "regex": RunSpec(), + "safe_divide": RunSpec(fn="safe_divide", args=("3", "10")), + "scoreboard": RunSpec(), + # The committed on-disk fixture, threaded the way + # `tests/test_db_runtime.py::TestDbOnDiskExample229` threads it — without + # it the example takes its graceful in-memory `Err` arm and the on-disk + # read, which is the whole point of the example, never executes. + "sqlitedb": RunSpec(needs_db_fixture=True), + "string_ops": RunSpec(), + "string_utilities": RunSpec(fn="padded_id"), + "url_encoding": RunSpec(), + "url_parsing": RunSpec(), +} + + +# The 8 examples excluded by property. Each is still type-checked, +# verified and compiled by the other gates; only execution is out of +# reach here. +SKIPS: dict[str, str] = { + "async_http_fanout": "network", + "http": "network", + "inference": "api-key", + "inference_json": "api-key", + "io_operations": "stdin", + "read_char": "stdin", + "http_server": "non-scalar-entry", + "life": "long-running", +} + + +# Environment variables that change what an example *does*. The runner +# strips them from the inherited environment so a gate run measures the +# examples rather than the developer's shell — an ambient `VERA_DB_URL` +# would otherwise point `database.vera` at a real database, and an +# ambient provider key would turn a run into a billed API call. A spec +# that needs one puts its own value back. +NEUTRALISED_ENV: tuple[str, ...] = ( + "VERA_DB_URL", + "VERA_ANTHROPIC_API_KEY", + "VERA_OPENAI_API_KEY", + "VERA_MOONSHOT_API_KEY", + "VERA_MISTRAL_API_KEY", + "VERA_XAI_API_KEY", + "VERA_DEEPSEEK_API_KEY", +) + + +# Per-example wall-clock budget. Generous: the whole 34-program set runs +# in a few seconds, so this only ever fires on a genuine hang, which is +# reported as a failure rather than blocking the hook indefinitely. +TIMEOUT_SECONDS = 300 + + +# --------------------------------------------------------------------------- +# The coverage rule +# --------------------------------------------------------------------------- + + +def example_names(examples_dir: Path) -> list[str]: + """Every standalone example program, by stem. + + Non-recursive by design: `examples/vera/` holds the modules + `modules.vera` imports, which are libraries rather than programs and + have no entry point of their own. + """ + return sorted(p.stem for p in examples_dir.glob("*.vera")) + + +def check_coverage( + names: list[str], + run_specs: dict[str, RunSpec], + skips: dict[str, str], +) -> list[str]: + """Every name classified exactly once, every classification real. + + An empty corpus is an error rather than a vacuous pass: a glob that + stops matching would otherwise switch the whole gate off silently, + which is the failure this script is built to make impossible. + """ + errors: list[str] = [] + if not names: + errors.append( + "no examples found — the corpus glob matched nothing. This is " + "an error rather than a pass: a gate with nothing to run " + "reports success while covering zero programs." + ) + return errors + + on_disk = set(names) + classified = set(run_specs) | set(skips) + + for name in sorted(on_disk - classified): + errors.append( + f"{name}.vera is unclassified — add it to RUN_SPECS with an " + f"entry point, or to SKIPS with a property from " + f"SKIP_PROPERTIES explaining why the harness cannot run it." + ) + + for name in sorted(classified - on_disk): + table = "RUN_SPECS" if name in run_specs else "SKIPS" + errors.append( + f"{name!r} is listed in {table} but no examples/{name}.vera " + f"exists — remove the stale entry (a suppression outliving its " + f"example would mask a later program of the same name)." + ) + + for name in sorted(set(run_specs) & set(skips)): + errors.append( + f"{name}.vera appears in both tables — an example is either " + f"run or skipped, never both." + ) + + for name, prop in sorted(skips.items()): + if prop not in SKIP_PROPERTIES: + errors.append( + f"{name}.vera is skipped for {prop!r}, which is not in " + f"SKIP_PROPERTIES — every suppression must cite a " + f"documented property so the report can state the reason." + ) + + return errors + + +# --------------------------------------------------------------------------- +# Invocation +# --------------------------------------------------------------------------- + + +def build_command(python: str, vera_file: Path, spec: RunSpec) -> list[str]: + """The `vera run` argv for one example.""" + cmd = [python, "-m", "vera.cli", "run", str(vera_file)] + if spec.fn is not None: + cmd += ["--fn", spec.fn] + if spec.args: + cmd += ["--", *spec.args] + return cmd + + +def spec_env(spec: RunSpec, examples_dir: Path) -> dict[str, str]: + """The environment a spec adds on top of the inherited one.""" + if not spec.needs_db_fixture: + return {} + fixture = (examples_dir / "sqlitedb.sqlite").resolve() + # POSIX form so the URL is portable on Windows, matching + # `tests/test_db_runtime.py`; `_open_connection` strips the prefix + # back to the filesystem path. + return {"VERA_DB_URL": f"sqlite:///{fixture.as_posix()}"} + + +def build_env( + spec: RunSpec, + examples_dir: Path, + base: dict[str, str] | None = None, +) -> dict[str, str]: + """The full environment for one run: inherited, minus the variables + that would change the example's behaviour, plus the spec's own.""" + env = dict(os.environ if base is None else base) + for name in NEUTRALISED_ENV: + env.pop(name, None) + env.update(spec_env(spec, examples_dir)) + return env + + +def run_corpus( + examples_dir: Path, + run_specs: dict[str, RunSpec], + workdir_root: Path, +) -> list[str]: + """Run every spec; return one formatted line per failure. + + Each example gets its own scratch working directory. `file_io.vera` + writes `hello.txt` relative to the process CWD, so running in place + would drop artefacts beside the examples on every gate run. + """ + failures: list[str] = [] + for name in sorted(run_specs): + spec = run_specs[name] + vera_file = examples_dir / f"{name}.vera" + if not vera_file.is_file(): + failures.append( + f"{name}: examples/{name}.vera does not exist, so the " + f"RUN_SPECS entry covers nothing" + ) + continue + + workdir = workdir_root / f"run-{name}" + workdir.mkdir(parents=True, exist_ok=True) + try: + result = subprocess.run( + build_command(sys.executable, vera_file, spec), + capture_output=True, + text=True, + encoding="utf-8", + cwd=str(workdir), + # Nothing may consume the invoking terminal's stdin: an + # example that read it would hang the hook, and the ones + # that do are skipped for exactly that reason. + stdin=subprocess.DEVNULL, + env=build_env(spec, examples_dir), + timeout=TIMEOUT_SECONDS, + check=False, + ) + except subprocess.TimeoutExpired: + failures.append( + f"{name}: exceeded the {TIMEOUT_SECONDS}s budget — the " + f"program hung rather than terminating" + ) + continue + + if result.returncode != 0: + detail = (result.stderr.strip() or result.stdout.strip() + or "no output") + failures.append( + f"{name}: `vera run` exited {result.returncode}: " + f"{detail[:300]}" + ) + return failures + + +# --------------------------------------------------------------------------- +# The TESTING.md cross-check +# --------------------------------------------------------------------------- + + +_TABLE_HEADING = "Example execution coverage" +_ROW_RE = re.compile(r"^\|\s*`([A-Za-z0-9_]+)\.vera`\s*\|[^|]*\|\s*([^|]+?)\s*\|") + + +def parse_testing_table(text: str) -> dict[str, str] | None: + """The example → harness-disposition map from TESTING.md's table. + + ``None`` when the heading is absent — distinct from an empty dict + (heading present, no rows), because the two need different messages + and neither may be reported as a pass. + """ + lines = text.splitlines() + start = None + for i, line in enumerate(lines): + if line.startswith("#") and _TABLE_HEADING in line: + start = i + break + if start is None: + return None + + rows: dict[str, str] = {} + for line in lines[start + 1:]: + if line.startswith("#"): # the next heading ends the subsection + break + m = _ROW_RE.match(line) + if m: + rows[m.group(1)] = m.group(2).strip() + return rows + + +def check_testing_md( + text: str, + run_specs: dict[str, RunSpec], + skips: dict[str, str], +) -> list[str]: + """TESTING.md's table must agree with this script's classification.""" + rows = parse_testing_table(text) + if rows is None: + return [ + f"TESTING.md: no heading containing {_TABLE_HEADING!r} — the " + f"execution-model table is the documented form of this " + f"script's classification, and a reworded heading must fail " + f"the gate rather than leave nothing to compare." + ] + if not rows: + return [ + f"TESTING.md: the {_TABLE_HEADING!r} section has no rows the " + f"gate can read — expected one `| `.vera` | ... | " + f" |` row per example." + ] + + expected = {name: "runs" for name in run_specs} + expected.update({name: f"skip: {prop}" for name, prop in skips.items()}) + + errors: list[str] = [] + for name in sorted(set(expected) - set(rows)): + errors.append( + f"TESTING.md: no row for {name}.vera, which this script " + f"classifies as {expected[name]!r}" + ) + for name in sorted(set(rows) - set(expected)): + errors.append( + f"TESTING.md: row for {name}.vera, which this script does not " + f"classify — the example was renamed or removed, or the row " + f"was never real" + ) + for name in sorted(set(rows) & set(expected)): + if rows[name] != expected[name]: + errors.append( + f"TESTING.md: {name}.vera is documented as " + f"{rows[name]!r} but this script classifies it as " + f"{expected[name]!r}" + ) + return errors + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def main() -> int: + repo_root = Path(__file__).resolve().parent.parent + examples_dir = repo_root / "examples" + + names = example_names(examples_dir) + coverage_errors = check_coverage(names, RUN_SPECS, SKIPS) + + testing_md = repo_root / "TESTING.md" + if testing_md.is_file(): + doc_errors = check_testing_md( + testing_md.read_text(encoding="utf-8"), RUN_SPECS, SKIPS + ) + else: + doc_errors = [f"TESTING.md not found at {testing_md}"] + + # The coverage rule gates the run: with the tables out of sync with + # disk, a green run would be reporting on the wrong set of programs. + if coverage_errors: + print( + f"COVERAGE ERRORS ({len(coverage_errors)}):", file=sys.stderr + ) + for e in coverage_errors: + print(f" {e}", file=sys.stderr) + for e in doc_errors: + print(f" {e}", file=sys.stderr) + return 1 + + with tempfile.TemporaryDirectory(prefix="vera-examples-run-") as td: + failures = run_corpus(examples_dir, RUN_SPECS, Path(td)) + + print( + f"Ran {len(RUN_SPECS)} of {len(names)} examples under the native " + f"runtime ({len(SKIPS)} skipped by property)." + ) + for prop in sorted(SKIP_PROPERTIES): + skipped = sorted(n for n, p in SKIPS.items() if p == prop) + if skipped: + print(f" skip [{prop}]: {', '.join(skipped)}") + print(f" {SKIP_PROPERTIES[prop]}") + + if doc_errors: + print( + f"\nDOCUMENTATION MISMATCH ({len(doc_errors)}):", file=sys.stderr + ) + for e in doc_errors: + print(f" {e}", file=sys.stderr) + print( + "\nTESTING.md's execution-model table is the documented form " + "of the classification in this script. Update the table to " + "match, so the model stays readable without reading the " + "source.", + file=sys.stderr, + ) + + if failures: + print( + f"\nRUNTIME FAILURES ({len(failures)} example(s) that pass " + f"check, verify and compile but do not run):", + file=sys.stderr, + ) + for f in failures: + print(f" {f}", file=sys.stderr) + print( + "\nAn example that no longer runs is a bug in the compiler or " + "in the example, not something to suppress: SKIPS is for " + "programs the harness structurally cannot drive, not for ones " + "that are broken.", + file=sys.stderr, + ) + + if doc_errors or failures: + return 1 + + print("\nAll runnable examples execute trap-free.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_check_examples_run.py b/tests/test_check_examples_run.py new file mode 100644 index 00000000..55959b43 --- /dev/null +++ b/tests/test_check_examples_run.py @@ -0,0 +1,505 @@ +"""Tests for scripts/check_examples_run.py — the harness gate that RUNS +the examples. + +The gate's design has three separable parts, and each is tested here: + +- **The coverage rule** (`check_coverage`) — every ``examples/*.vera`` on + disk is either in ``RUN_SPECS`` or in ``SKIPS``. An unclassified + example is an ERROR, so adding an example forces classifying it; a + table key with no file on disk is an ERROR too, so a deleted example + cannot leave a suppression behind. +- **The documentation cross-check** (`check_testing_md`) — TESTING.md's + execution-model table must agree with the script's own classification, + the `check_doc_counts.py` model: the codebase is the oracle and the doc + must match it. +- **The runner** (`run_corpus`) — a seeded corpus proves the gate goes red + on a program that traps at runtime and green on one that does not. + +Two conventions are inherited from ``tests/test_check_doc_counts.py`` and +asserted throughout: a regex or glob that matches nothing must be an +ERROR rather than a silent pass (otherwise a rewording switches the gate +off), and each check is exercised in both directions — a passing case +alongside the failing one it is supposed to catch, so a check that can +only ever return ``[]`` cannot masquerade as green. +""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +from typing import Any + +_SCRIPT = Path(__file__).parent.parent / "scripts" / "check_examples_run.py" +_ROOT = Path(__file__).parent.parent + + +def _load() -> Any: + spec = importlib.util.spec_from_file_location("check_examples_run", _SCRIPT) + assert spec is not None + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +_MOD = _load() + + +# --------------------------------------------------------------------------- +# Synthetic corpora +# --------------------------------------------------------------------------- + +# A program that returns a value and exits cleanly. +_CLEAN_SRC = """\ +public fn main(-> @Int) + requires(true) + ensures(true) + effects(pure) +{ + 1 + 1 +} +""" + +# A program that type-checks and compiles but traps at runtime: the index +# is out of bounds, so the emitted bounds guard fires. This is the shape +# the gate exists to catch — `vera check` and `vera verify` both accept a +# program whose runtime behaviour is broken. +_TRAPPING_SRC = """\ +public fn main(-> @Int) + requires(true) + ensures(true) + effects(pure) +{ + let @Array = [1, 2, 3]; + @Array.0[10] +} +""" + + +def _corpus(tmp_path: Path, programs: dict[str, str]) -> Path: + d = tmp_path / "examples" + d.mkdir(exist_ok=True) + for name, src in programs.items(): + (d / f"{name}.vera").write_text(src, encoding="utf-8") + return d + + +# --------------------------------------------------------------------------- +# The coverage rule +# --------------------------------------------------------------------------- + + +class TestCoverageRule: + """Every example is classified; nothing is classified that isn't there.""" + + def test_real_corpus_is_fully_classified(self) -> None: + """The shipped tables cover the shipped corpus — no unclassified + example, no stale key. This is the assertion that goes red when + somebody adds `examples/new_thing.vera` without deciding whether + the harness can run it.""" + names = _MOD.example_names(_ROOT / "examples") + assert _MOD.check_coverage(names, _MOD.RUN_SPECS, _MOD.SKIPS) == [] + + def test_every_example_on_disk_appears_in_exactly_one_table(self) -> None: + names = set(_MOD.example_names(_ROOT / "examples")) + assert names == set(_MOD.RUN_SPECS) | set(_MOD.SKIPS) + assert not (set(_MOD.RUN_SPECS) & set(_MOD.SKIPS)) + + def test_unclassified_example_is_an_error(self) -> None: + """The heart of the anti-rot rule: an example on disk that neither + table names fails the gate.""" + errors = _MOD.check_coverage( + ["known", "brand_new"], {"known": _MOD.RunSpec()}, {} + ) + assert len(errors) == 1 + assert "brand_new" in errors[0] + assert "unclassified" in errors[0].lower() + + def test_stale_run_spec_is_an_error(self) -> None: + """A RUN_SPECS key whose file was deleted or renamed is an error — + otherwise the entry silently stops covering anything.""" + errors = _MOD.check_coverage( + ["known"], + {"known": _MOD.RunSpec(), "deleted": _MOD.RunSpec()}, + {}, + ) + assert len(errors) == 1 + assert "deleted" in errors[0] + + def test_stale_skip_is_an_error(self) -> None: + """The same for a skip: a suppression outliving its example would + quietly mask a re-added example with the same name.""" + errors = _MOD.check_coverage( + ["known"], {"known": _MOD.RunSpec()}, {"gone": "network"} + ) + assert len(errors) == 1 + assert "gone" in errors[0] + + def test_example_in_both_tables_is_an_error(self) -> None: + """Ambiguous classification: which one wins is not a question the + gate should have to answer.""" + errors = _MOD.check_coverage( + ["both"], {"both": _MOD.RunSpec()}, {"both": "network"} + ) + assert any("both" in e and "both tables" in e for e in errors) + + def test_unknown_skip_property_is_an_error(self) -> None: + """A skip must cite a property from the documented set, so every + suppression carries a stated reason the report can print.""" + errors = _MOD.check_coverage(["x"], {}, {"x": "because-i-said-so"}) + assert len(errors) == 1 + assert "because-i-said-so" in errors[0] + assert "SKIP_PROPERTIES" in errors[0] + + def test_empty_corpus_is_an_error(self) -> None: + """A glob that matches nothing is an ERROR, not a vacuous pass. + Without this the gate reports success the moment its glob stops + matching — the failure mode the whole check exists to rule out.""" + errors = _MOD.check_coverage([], {}, {}) + assert len(errors) == 1 + assert "no examples" in errors[0].lower() + + def test_example_names_reads_from_disk(self, tmp_path: Path) -> None: + d = _corpus(tmp_path, {"b": _CLEAN_SRC, "a": _CLEAN_SRC}) + assert _MOD.example_names(d) == ["a", "b"] + + def test_example_names_ignores_nested_module_libraries( + self, tmp_path: Path + ) -> None: + """`examples/vera/` holds the modules `modules.vera` imports. They + are not standalone programs and the corpus glob must not treat them + as unclassified examples.""" + d = _corpus(tmp_path, {"top": _CLEAN_SRC}) + nested = d / "vera" + nested.mkdir() + (nested / "lib.vera").write_text(_CLEAN_SRC, encoding="utf-8") + assert _MOD.example_names(d) == ["top"] + + +class TestRunSpecsAreWellFormed: + """The specs must name entry points that exist, or the gate would be + measuring `vera run`'s argument handling rather than the examples.""" + + def test_every_named_fn_is_public_in_its_example(self) -> None: + bad = [] + for name, spec in _MOD.RUN_SPECS.items(): + if spec.fn is None: + continue + src = (_ROOT / "examples" / f"{name}.vera").read_text( + encoding="utf-8" + ) + if f"public fn {spec.fn}(" not in src: + bad.append(f"{name}: no `public fn {spec.fn}`") + assert bad == [] + + def test_examples_without_main_carry_an_explicit_entry_point(self) -> None: + """`vera run` with no `--fn` falls back to the first export, which + for a no-main example is an arbitrary function that usually needs + arguments. Every runnable no-main example must therefore pin its + entry point.""" + bad = [] + for name, spec in _MOD.RUN_SPECS.items(): + src = (_ROOT / "examples" / f"{name}.vera").read_text( + encoding="utf-8" + ) + if "public fn main(" not in src and spec.fn is None: + bad.append(name) + assert bad == [] + + def test_every_skip_property_is_documented(self) -> None: + for name, prop in _MOD.SKIPS.items(): + assert prop in _MOD.SKIP_PROPERTIES, name + assert _MOD.SKIP_PROPERTIES[prop].strip(), prop + + def test_no_unused_skip_property(self) -> None: + """A property nothing cites is dead documentation that would drift.""" + assert set(_MOD.SKIP_PROPERTIES) == set(_MOD.SKIPS.values()) + + +class TestBuildCommand: + def test_main_entry_point_passes_no_fn_flag(self) -> None: + cmd = _MOD.build_command("py", Path("/x/a.vera"), _MOD.RunSpec()) + assert "--fn" not in cmd + assert cmd[:4] == ["py", "-m", "vera.cli", "run"] + + def test_named_entry_point_and_args(self) -> None: + cmd = _MOD.build_command( + "py", Path("/x/a.vera"), _MOD.RunSpec(fn="f", args=("1", "-2")) + ) + assert cmd[-5:] == ["--fn", "f", "--", "1", "-2"] + + def test_db_fixture_env_points_at_the_committed_sqlite(self) -> None: + env = _MOD.spec_env( + _MOD.RunSpec(needs_db_fixture=True), _ROOT / "examples" + ) + assert env["VERA_DB_URL"].startswith("sqlite:///") + assert env["VERA_DB_URL"].endswith("examples/sqlitedb.sqlite") + + def test_no_db_fixture_adds_no_env(self) -> None: + assert _MOD.spec_env(_MOD.RunSpec(), _ROOT / "examples") == {} + + +class TestHermeticEnvironment: + """The gate must measure the examples, not the developer's shell. + + `database.vera` and `sqlitedb.vera` both read `VERA_DB_URL`, and the + inference examples read six provider keys. An ambient value would + change what the gate exercises — at worst pointing a run at somebody's + real database — so the runner strips them and puts back only what a + spec explicitly asks for. + """ + + def test_ambient_db_url_is_stripped(self) -> None: + env = _MOD.build_env( + _MOD.RunSpec(), _ROOT / "examples", + {"VERA_DB_URL": "postgres://prod/live", "PATH": "/bin"}, + ) + assert "VERA_DB_URL" not in env + assert env["PATH"] == "/bin" + + def test_ambient_provider_keys_are_stripped(self) -> None: + base = {f"VERA_{p}_API_KEY": "sk-real" for p in + ("ANTHROPIC", "OPENAI", "MOONSHOT", "MISTRAL", "XAI", + "DEEPSEEK")} + env = _MOD.build_env(_MOD.RunSpec(), _ROOT / "examples", base) + assert [k for k in env if k.endswith("_API_KEY")] == [] + + def test_fixture_spec_puts_its_own_db_url_back(self) -> None: + env = _MOD.build_env( + _MOD.RunSpec(needs_db_fixture=True), _ROOT / "examples", + {"VERA_DB_URL": "postgres://prod/live"}, + ) + assert env["VERA_DB_URL"].startswith("sqlite:///") + assert "prod" not in env["VERA_DB_URL"] + + def test_every_neutralised_name_is_actually_read_by_an_example( + self, + ) -> None: + """Neutralising a variable nothing reads is dead configuration. + Each name must appear in the runtime that backs the effect the + examples use, so the list shrinks when a provider goes away.""" + runtime = (_ROOT / "vera" / "runtime").rglob("*.py") + blob = "\n".join(p.read_text(encoding="utf-8") for p in runtime) + for name in _MOD.NEUTRALISED_ENV: + assert name in blob, name + + +# --------------------------------------------------------------------------- +# The runner +# --------------------------------------------------------------------------- + + +class TestRunnerGoesRedOnRuntimeFailure: + """The proving test: a seeded corpus whose program traps must fail the + gate. A runner that never reports a failure would pass every other + test in this file.""" + + def test_trapping_example_fails_the_gate(self, tmp_path: Path) -> None: + d = _corpus(tmp_path, {"boom": _TRAPPING_SRC}) + failures = _MOD.run_corpus(d, {"boom": _MOD.RunSpec()}, tmp_path) + assert len(failures) == 1 + assert "boom" in failures[0] + + def test_clean_example_passes_the_gate(self, tmp_path: Path) -> None: + d = _corpus(tmp_path, {"fine": _CLEAN_SRC}) + assert _MOD.run_corpus(d, {"fine": _MOD.RunSpec()}, tmp_path) == [] + + def test_mixed_corpus_reports_only_the_broken_one( + self, tmp_path: Path + ) -> None: + """Chosen so a runner that reported every program, or none, is + distinguishable from one that reports the right one.""" + d = _corpus(tmp_path, {"fine": _CLEAN_SRC, "boom": _TRAPPING_SRC}) + failures = _MOD.run_corpus( + d, {"fine": _MOD.RunSpec(), "boom": _MOD.RunSpec()}, tmp_path + ) + assert len(failures) == 1 + assert "boom" in failures[0] + assert "fine" not in failures[0] + + def test_named_entry_point_is_actually_invoked( + self, tmp_path: Path + ) -> None: + """A runner that ignored ``spec.fn`` would call the first export and + pass. Here the first export is clean and the named one traps, so + only a runner that honours the spec goes red.""" + src = """\ +public fn first(-> @Int) + requires(true) + ensures(true) + effects(pure) +{ + 0 +} + +public fn second(-> @Int) + requires(true) + ensures(true) + effects(pure) +{ + let @Array = [1]; + @Array.0[9] +} +""" + d = _corpus(tmp_path, {"pick": src}) + assert _MOD.run_corpus(d, {"pick": _MOD.RunSpec()}, tmp_path) == [] + failures = _MOD.run_corpus( + d, {"pick": _MOD.RunSpec(fn="second")}, tmp_path + ) + assert len(failures) == 1 + + def test_runner_does_not_write_into_the_corpus( + self, tmp_path: Path + ) -> None: + """`file_io.vera` writes `hello.txt` relative to the process CWD. + The runner must give each example a scratch working directory so a + gate run leaves no artefact beside the examples.""" + src = """\ +public fn main(-> @Unit) + requires(true) + ensures(true) + effects() +{ + match IO.write_file("side_effect.txt", "x") { + Ok(_) -> (), + Err(@String) -> IO.print(@String.0) + }; + + () +} +""" + d = _corpus(tmp_path, {"writer": src}) + before = sorted(p.name for p in d.iterdir()) + assert _MOD.run_corpus(d, {"writer": _MOD.RunSpec()}, tmp_path) == [] + assert sorted(p.name for p in d.iterdir()) == before + + def test_missing_file_for_a_spec_is_a_failure( + self, tmp_path: Path + ) -> None: + """Belt-and-braces against the runner silently skipping what it + cannot find; `check_coverage` catches this earlier, but the runner + must not turn a missing file into a pass on its own.""" + d = _corpus(tmp_path, {"present": _CLEAN_SRC}) + failures = _MOD.run_corpus( + d, {"present": _MOD.RunSpec(), "absent": _MOD.RunSpec()}, tmp_path + ) + assert len(failures) == 1 + assert "absent" in failures[0] + # The specific wording, not merely *a* failure: `vera run` on a path + # that isn't there exits non-zero of its own accord, so the generic + # exit-code arm reports this case even with the guard removed. What + # the guard adds is the diagnosis — that a RUN_SPECS entry covers + # nothing — and only asserting that makes the test able to tell the + # two apart. + assert "covers nothing" in failures[0] + + +# --------------------------------------------------------------------------- +# The TESTING.md cross-check +# --------------------------------------------------------------------------- + + +def _table(rows: list[tuple[str, str]]) -> str: + body = "\n".join( + f"| `{name}.vera` | some prose | {gate} |" for name, gate in rows + ) + return ( + "### Example execution coverage\n\n" + "| Example | Executed by | Harness gate |\n" + "|---------|-------------|--------------|\n" + f"{body}\n\n" + "## Next section\n" + ) + + +class TestTestingMdCrossCheck: + """docs must match the codebase — the `check_doc_counts.py` model.""" + + def test_shipped_testing_md_matches_the_shipped_tables(self) -> None: + text = (_ROOT / "TESTING.md").read_text(encoding="utf-8") + assert _MOD.check_testing_md(text, _MOD.RUN_SPECS, _MOD.SKIPS) == [] + + def test_matching_table_passes(self) -> None: + doc = _table([("a", "runs"), ("b", "skip: network")]) + errors = _MOD.check_testing_md( + doc, {"a": _MOD.RunSpec()}, {"b": "network"} + ) + assert errors == [] + + def test_missing_row_is_an_error(self) -> None: + doc = _table([("a", "runs")]) + errors = _MOD.check_testing_md( + doc, {"a": _MOD.RunSpec()}, {"b": "network"} + ) + assert len(errors) == 1 + assert "b.vera" in errors[0] + + def test_extra_row_is_an_error(self) -> None: + doc = _table([("a", "runs"), ("ghost", "runs")]) + errors = _MOD.check_testing_md(doc, {"a": _MOD.RunSpec()}, {}) + assert len(errors) == 1 + assert "ghost" in errors[0] + + def test_renamed_example_is_an_error_naming_both_sides(self) -> None: + """The rename case: the doc still cites the old name and the script + the new one, so the reader is told which is which rather than just + that a count is off.""" + doc = _table([("old_name", "runs")]) + errors = _MOD.check_testing_md(doc, {"new_name": _MOD.RunSpec()}, {}) + joined = " ".join(errors) + assert "old_name" in joined + assert "new_name" in joined + + def test_wrong_gate_disposition_is_an_error(self) -> None: + doc = _table([("a", "skip: network")]) + errors = _MOD.check_testing_md(doc, {"a": _MOD.RunSpec()}, {}) + assert len(errors) == 1 + assert "a.vera" in errors[0] + + def test_wrong_skip_property_is_an_error(self) -> None: + """Same disposition, different property: documenting `http.vera` as + skipped for the wrong reason is a lie the gate must catch.""" + doc = _table([("a", "skip: stdin")]) + errors = _MOD.check_testing_md(doc, {}, {"a": "network"}) + assert len(errors) == 1 + assert "network" in errors[0] + + def test_reworded_heading_is_an_error_not_a_silent_pass(self) -> None: + """The convention this whole file turns on: if the anchor the check + keys off is reworded away, the check must fail loudly rather than + find nothing to compare and report success.""" + doc = ( + "### Some other heading entirely\n\n" + "| Example | Executed by | Harness gate |\n" + "|---------|-------------|--------------|\n" + "| `a.vera` | prose | runs |\n" + ) + errors = _MOD.check_testing_md(doc, {"a": _MOD.RunSpec()}, {}) + assert len(errors) == 1 + # The *missing-heading* diagnosis specifically. Both this branch and + # the empty-table one below name the heading, so asserting the + # heading alone passes whichever fires — the coinciding-message trap. + # `parse_testing_table` returns None here and an empty dict there, + # and `None` is falsy, so a missing-heading case that fell through + # would be reported as an empty table and read as a pass. + assert "no heading containing" in errors[0] + assert "Example execution coverage" in errors[0] + + def test_heading_present_but_table_empty_is_an_error(self) -> None: + doc = ( + "### Example execution coverage\n\n" + "The table went away in a refactor.\n\n" + "## Next section\n" + ) + errors = _MOD.check_testing_md(doc, {"a": _MOD.RunSpec()}, {}) + assert len(errors) == 1 + assert "no rows" in errors[0].lower() + + def test_table_parse_stops_at_the_next_heading(self) -> None: + """A row-shaped line in a later section must not be swept in as an + example row — the parse is scoped to the subsection.""" + doc = _table([("a", "runs")]).replace( + "## Next section\n", + "## Next section\n\n| `ghost.vera` | x | runs |\n", + ) + assert _MOD.check_testing_md(doc, {"a": _MOD.RunSpec()}, {}) == [] From 7c1191e1f5c01caf96e86625bcd3812e08320e1c Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 11:55:17 +0100 Subject: [PATCH 33/69] State the newly-covered example count as the audit measured it The execution-coverage section said twelve examples had been executed by nothing before the gate. Seventeen were; the gate runs eleven of them and a property excludes the other six, with file_io -- previously browser-only -- making a twelfth newly executed natively. Co-Authored-By: Claude --- TESTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TESTING.md b/TESTING.md index a3635314..9b3f1da5 100644 --- a/TESTING.md +++ b/TESTING.md @@ -679,7 +679,7 @@ What the gate asserts is *runs green*, deliberately not *prints what it used to* | `url_encoding.vera` | browser parity (stdout) | runs | | `url_parsing.vera` | browser parity (stdout) | runs | -Twelve examples in that table were executed by nothing at all before the gate, and one — `file_io.vera` — was executed only under the browser runtime, where the file IO it demonstrates is a deliberate `Err` stub. +Seventeen of those examples were executed by nothing at all before the gate. It runs eleven of them; the remaining six are the ones a property excludes. A twelfth example joins them natively — `file_io.vera`, which ran only under the browser runtime, where the file IO it demonstrates is a deliberate `Err` stub. Each skip cites a property, and the gate prints the property and its reason on every run: From 760858255971e758e0974acc9983ab8f86ceda06 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 12:38:19 +0100 Subject: [PATCH 34/69] Assert the examples run on two signals, not just an exit code check_examples.py asserts the exit code AND an output sentinel, and the reason applies here: either alone can be satisfied by the wrong thing. Two measured failures, both at exit 0. Privatise main in array_utilities and `vera run` fell back to the first export, ran a different function and the gate passed. Every spec now names its entry point, so the CLI resolves it by name and exits 1 -- "Function 'main' is declared private" for the privatised case, "not found in exports" for a rename. That is the structural form of the must-exist check rather than a source scan in the script: the CLI already owns entry-point resolution, and a well-formedness scan would have restated it in a second place. Delete examples/sqlitedb.sqlite and the gate stayed green, because the example answers a missing fixture by printing a message and completing normally -- while the RUN_SPECS comment beside it says the on-disk read is the whole point. A spec may now carry an `expect` substring that only its success path prints, set for the three examples that reach outside the process: sqlitedb for its committed fixture, database for an in-memory database, file_io for the filesystem. It is not a stdout pin; the dedicated tests keep that job. Three smaller repairs alongside. The two stale-entry tests asserted only the example name, so they could not tell a stale RUN_SPECS key from a stale SKIPS one -- the coinciding-message species this suite already fixed twice; they now assert the table name in both directions, and two new mutants confirm it. Documentation mismatches printed on the coverage-error path sat under a COVERAGE ERRORS header whose count excluded them; error_blocks now gives each kind its own counted block on both paths. And the stdin comment claimed an example reading stdin would hang the hook, which is measurably false under DEVNULL -- terminal dependence, the reason SKIP_PROPERTIES already gives, is the accurate one. Co-Authored-By: Claude --- CHANGELOG.md | 2 +- TESTING.md | 8 +- scripts/check_examples_run.py | 205 +++++++++++++++++++-------- tests/test_check_examples_run.py | 234 ++++++++++++++++++++++++++++--- 4 files changed, 367 insertions(+), 82 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25802a7e..41f0d782 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added -- **The examples are now RUN in CI, not only checked, verified and compiled** (`scripts/check_examples_run.py`). `check_examples.py` type-checks and verifies all 42, and `check_e602_clean.py` compiles all 42 as a side effect of policing silent translator skips — but nothing executed them as a set, and an audit of every referencing test found **seventeen examples that no test ran at all**: `array_utilities`, `async_http_fanout`, `collections`, `database`, `fizzbuzz`, `html`, `http`, `inference`, `io_operations`, `json`, `life`, `maximum_syntax`, `modules`, `nested_closures`, `read_char`, `scoreboard` and `string_utilities`, plus `file_io`, which ran only under the browser runtime, where the file IO it demonstrates is a deliberate `Err` stub. Between them they demonstrate `Map`/`Set`, the `` effect, JSON and HTML parsing, module imports and the whole string-utility family, so a runtime regression in any of it could reach a release with every gate green. The gate now runs 34 of the 42 under the native runtime and asserts a trap-free exit; the other 8 carry a documented skip property (`network`, `api-key`, `stdin`, `non-scalar-entry`, `long-running`) which the report prints with its reason on every run. Output pinning deliberately stays in the dedicated tests that already do it, so the gate does not go red on a cosmetic edit to an example. The load-bearing part is not the runs but the **coverage rule**: the script enumerates `examples/*.vera` from disk and requires every name to be in exactly one of its two tables, so an unclassified example is an error and adding one forces the author to decide whether the harness can drive it — and a table key whose file is gone is an error too, so a suppression cannot outlive its example and mask a later program of the same name. The classification is cross-checked against a new execution-coverage table in `TESTING.md` on the `check_doc_counts.py` model, the codebase being the oracle and the documentation having to match it, so the execution model stops living in maintainers' heads. Runs are hermetic: an ambient `VERA_DB_URL` or inference-provider key is stripped from the environment, so a gate run cannot be pointed at a real database or turned into a billed API request, and each example gets a scratch working directory so `file_io.vera` stops dropping `hello.txt` beside the sources. `TESTING.md`'s round-trip section is corrected with them — it claimed all 42 examples were tested through "every pipeline stage ... WASM compilation, and execution", where the directory-globbing parametrised tests in fact stop at verification and canonical form. +- **The examples are now RUN in CI, not only checked, verified and compiled** (`scripts/check_examples_run.py`). `check_examples.py` type-checks and verifies all 42, and `check_e602_clean.py` compiles all 42 as a side effect of policing silent translator skips — but nothing executed them as a set, and an audit of every referencing test found **seventeen examples that no test ran at all**: `array_utilities`, `async_http_fanout`, `collections`, `database`, `fizzbuzz`, `html`, `http`, `inference`, `io_operations`, `json`, `life`, `maximum_syntax`, `modules`, `nested_closures`, `read_char`, `scoreboard` and `string_utilities`, plus `file_io`, which ran only under the browser runtime, where the file IO it demonstrates is a deliberate `Err` stub. Between them they demonstrate `Map`/`Set`, the `` effect, JSON and HTML parsing, module imports and the whole string-utility family, so a runtime regression in any of it could reach a release with every gate green. The gate now runs 34 of the 42 under the native runtime and asserts a trap-free exit; the other 8 carry a documented skip property (`network`, `api-key`, `stdin`, `non-scalar-entry`, `long-running`) which the report prints with its reason on every run. Output pinning deliberately stays in the dedicated tests that already do it, so the gate does not go red on a cosmetic edit to an example. The load-bearing part is not the runs but the **coverage rule**: the script enumerates `examples/*.vera` from disk and requires every name to be in exactly one of its two tables, so an unclassified example is an error and adding one forces the author to decide whether the harness can drive it — and a table key whose file is gone is an error too, so a suppression cannot outlive its example and mask a later program of the same name. The classification is cross-checked against a new execution-coverage table in `TESTING.md` on the `check_doc_counts.py` model, the codebase being the oracle and the documentation having to match it, so the execution model stops living in maintainers' heads. Trap-freedom is asserted on two signals, the discipline `check_examples.py` already applies: the exit code, and an output signal. Either alone accepts a measured failure. Every spec names its entry point rather than relying on `vera run`'s first-export fallback — with `main` privatised, `array_utilities.vera` ran a different function and the gate passed; it now exits 1 on the name. And the three examples that reach outside the process (`sqlitedb.vera` for its committed fixture, `database.vera` for an in-memory database, `file_io.vera` for the filesystem) answer a failure by printing a message and completing normally, so each pins a substring only its success path prints — deleting `examples/sqlitedb.sqlite` left the gate green on the graceful in-memory arm, and now fails on the sentinel. Runs are hermetic: an ambient `VERA_DB_URL` or inference-provider key is stripped from the environment, so a gate run cannot be pointed at a real database or turned into a billed API request, and each example gets a scratch working directory so `file_io.vera` stops dropping `hello.txt` beside the sources. `TESTING.md`'s round-trip section is corrected with them — it claimed all 42 examples were tested through "every pipeline stage ... WASM compilation, and execution", where the directory-globbing parametrised tests in fact stop at verification and canonical form. ### Fixed diff --git a/TESTING.md b/TESTING.md index 9b3f1da5..56ace2d3 100644 --- a/TESTING.md +++ b/TESTING.md @@ -215,7 +215,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_release.py` | 47 | 566 | Release policy and registry verification (#481): strict project-name and version parsing/comparison, version-bump/TestPyPI/recovery planning, exact confirmation and immutable-tag guards, first-parent version-introduction discovery, package-change recovery refusal, non-empty CHANGELOG extraction, one-wheel/one-sdist SHA-256 manifests, malformed registry-response handling, missing/filename/hash propagation retries, exact filename/hash verification, and CLI dispatch/GitHub-output wiring. An autouse fixture scrubs hook-exported `GIT_*` variables so the tmp-repo git calls (fixture helpers and `release.py`'s own) never resolve to the developer's repository when the suite runs inside a pre-commit hook. | | `test_check_doc_counts.py` | 54 | 665 | `check_doc_counts.py`'s pure per-document checks: KNOWN_ISSUES refactoring line counts (±10% tolerance band incl. the exact-boundary case, drift detection, empty-file citation, hyphenated paths, missing file/section/rows, the #419 empty-section sentinel + its cannot-mask-a-malformed-table dual), HISTORY version-row format (issue-link limit, ` — ` separator rejection, dateless-row and prose exemption, line-number reporting), the TESTING.md tests breakdown (parts summing to the collected total, a self-consistent-but-stale row, and the reworded-row error), and vera/README.md's Test Suite counts (all four checked independently — mutation-validated by dropping each citation in turn — plus the reworded-paragraph error, a thousands-separator case pinning that every one of the four counts is read comma-tolerantly, and the two section-anchoring cases — a reworded paragraph with decoy counts in a later section, and a renamed heading, both of which must fail loud rather than match across the section boundary). The reworded case is a test in its own right for both new checks: a pattern that matches nothing must be an error, or rewording the sentence silently switches the gate off. Also the release count (README's status line and HISTORY's total against each other and against `git tag`: the matching case, the one-ahead release cut that `release.yml` has not tagged yet, that +1 being the ONLY slack once the version is tagged, the two-behind drift that actually shipped, per-document reporting, and a tagless checkout standing the oracle down without standing down the cross-check), plus the tag reader itself against real repositories built in `tmp_path` — release tags read, `nightly`/`-rc1` not counted as releases, and both no-evidence answers (`None` rather than `[]`, since an empty list would read as zero releases and make every documented count wrong) for a tagless checkout and a directory that is not a repository at all. CONTRIBUTING.md's pre-commit hook count is checked the same way, reworded-sentence case included; and the CI-pipeline lint row against `ci.yml`'s lint job — a matching row, a step present in CI but absent from the row (the drift that shipped), a row entry CI no longer runs, the same set in a different order, a reworded row and a renamed job (both errors, not skips), that only the lint job is read rather than the whole workflow, and the shipped pair both clean and red with one entry dropped | | `test_grammar_alignment.py` | 44 | 309 | `check_grammar_alignment.py` gate ([#683](https://github.com/aallan/vera/issues/683)): both extractors (Lark headers with the `?`/`!`/`_` markers stripped, template parameters and rule priorities tolerated, and `-> alias` names deliberately not collected; spec headers from ```ebnf fences only), the allowlist arithmetic in all three directions — unwaived drift, a spent entry both files now have, and an entry whose premise broke — one case per waiver proving the fact it rests on is actually checked, the name-deleted-from-both-files case that must not read as agreement, a mutation restoring the spec's old `assert_stmt` name, non-vacuous extraction, and the false positive the issue itself rested on: `qualified_call` and `module_call` are spec headers Lark expresses as aliases, and must never be reported as drift. Three pin the premise checks against ways they used to pass vacuously: an alias surviving only inside a `//` comment must not hold its waiver up, an alias that moved to another rule must fail the waiver naming `fn_call`, and a spent waiver whose premise also broke must yield one instruction rather than two opposite ones | -| `test_check_examples_run.py` | 38 | 505 | `check_examples_run.py`, the harness gate that RUNS the examples. Three separable parts, each in both directions. **The coverage rule** — the shipped tables cover the shipped corpus exactly, and an unclassified example, a stale `RUN_SPECS` or `SKIPS` key whose file is gone, a name in both tables, and a skip citing an undocumented property are each an error; the empty corpus is an error too, since a glob that stops matching would otherwise report success over zero programs. Plus the specs' own well-formedness: every named entry point is `public` in its example, every no-main example pins one (or `vera run` would fall back to an arbitrary first export), and no skip property is unused. **The runner** — a seeded `tmp_path` corpus proves it goes red on a program that type-checks and compiles but traps at run time, green on one that does not, and reports only the broken member of a mixed pair; a fixture whose first export is clean and whose named one traps proves `spec.fn` is actually honoured rather than ignored; a writer program proves each run gets a scratch working directory, so a gate run leaves nothing beside the examples. **The TESTING.md cross-check** — missing row, extra row, rename (reported naming both sides), wrong disposition and wrong skip property are errors, the parse stops at the next heading so a row-shaped line in a later section is not swept in, and both a reworded heading and a heading whose table has vanished fail loud rather than finding nothing to compare. Also the hermetic-environment property: an ambient `VERA_DB_URL` or provider key is stripped so a gate run cannot be pointed at a real database or turned into a billed API call, a fixture spec puts its own URL back, and every neutralised name is checked to be one `vera/runtime/` actually reads | +| `test_check_examples_run.py` | 51 | 695 | `check_examples_run.py`, the harness gate that RUNS the examples. Four separable parts, each in both directions. **The coverage rule** — the shipped tables cover the shipped corpus exactly, and an unclassified example, a stale `RUN_SPECS` or `SKIPS` key whose file is gone, a name in both tables, and a skip citing an undocumented property are each an error; the empty corpus is an error too, since a glob that stops matching would otherwise report success over zero programs. Plus the specs' own well-formedness: every named entry point is `public` in its example, every no-main example pins one (or `vera run` would fall back to an arbitrary first export), and no skip property is unused. **The runner** — a seeded `tmp_path` corpus proves it goes red on a program that type-checks and compiles but traps at run time, green on one that does not, and reports only the broken member of a mixed pair; a fixture whose first export is clean and whose named one traps proves `spec.fn` is actually honoured rather than ignored; a writer program proves each run gets a scratch working directory, so a gate run leaves nothing beside the examples. **The TESTING.md cross-check** — missing row, extra row, rename (reported naming both sides), wrong disposition and wrong skip property are errors, the parse stops at the next heading so a row-shaped line in a later section is not swept in, and both a reworded heading and a heading whose table has vanished fail loud rather than finding nothing to compare. **The output signal** -- the second half of the two-signal discipline `check_examples.py` established: the fallback note that `vera run` prints when it cannot use the named entry point is a failure even at exit 0, an absent `expect` sentinel is a failure even at exit 0, and a spec without one asserts nothing about output; end to end, a privatised `main` and a program that completed down a graceful arm each go red, and the same program passes once its own output is the sentinel, so the check reads the output rather than always failing. The three environment-dependent specs are required to carry a sentinel, and the runner's use of BOTH streams is pinned structurally -- `vera run` writes the note to stderr and nothing there on a clean exit, so no fixture can distinguish reading both streams from reading stdout alone, and a tripwire wired to the wrong stream is no tripwire. Also the hermetic-environment property: an ambient `VERA_DB_URL` or provider key is stripped so a gate run cannot be pointed at a real database or turned into a billed API call, a fixture spec puts its own URL back, and every neutralised name is checked to be one `vera/runtime/` actually reads | | `test_check_editor_grammars.py` | 20 | 249 | `check_editor_grammars.py` gate ([#1156](https://github.com/aallan/vera/issues/1156)): the registry read (every effect in, every ability out, and the four names the grammars actually drifted on present so the set checked is non-vacuous), the word-boundary presence test across all three grammar formats (JSON, plist XML, Vim keyword list) including the prefix pair `Http`/`HttpServer` in both directions, the deliberate comment-mention false pass, a metacharacter pair that only passes when the name is matched literally (`A.B` present, `A0B` not), and the empty registry; the gate's primary path end to end — a listed grammar with an effect stripped out, and a listed README with one stripped out of its prose bullet, each red against an otherwise-clean mirrored tree; the completeness guard — a grammar discovered under `editors/` but absent from `GRAMMARS` fails that same tree, over three discovery routes (`.el` outside a syntax directory, a tree-sitter `.scm` query set, a `.tmLanguage.json` filed anywhere but `syntaxes/`); and the registry's provenance, run as a subprocess against a throwaway checkout whose `vera` package names an effect the grammars do not, which is the only way to see that the list comes from the tree being checked rather than from site-packages. The shipped grammars and READMEs are currently clean | | `test_check_explicit_encoding.py` | 54 | 254 | `check_explicit_encoding.py` gate (#645): flags text-mode `open()` / `read_text()` / `write_text()` **and** `subprocess.run/Popen/check_output(..., text=True)` captures missing an `encoding="utf-8"` literal (rejects non-literal / non-UTF-8 values), skips binary/bytes-mode calls, honours the `# encoding-exempt` opt-out, and asserts the shipped repo is clean | | `test_check_limitations_sync.py` | 6 | 108 | `check_limitations_sync.py` section extraction: table-rows-only issue harvesting, prose-link exemption, bounding at the next second-level heading, `None` for absent or sub-level headings so renamed sections fail loudly; plus the #852 fail-loud rule: an UNKNOWN issue state under `--check-states` (gh missing / auth failure / timeout) is an error, never a silent pass | @@ -634,6 +634,8 @@ The gate is what makes the set closed. It enumerates `examples/*.vera` from disk What the gate asserts is *runs green*, deliberately not *prints what it used to*: output pinning stays in the dedicated tests, which is why `sqlitedb.vera`'s rendered city table and `inference_json.vera`'s score line are pinned there and only trap-freedom here. +Trap-freedom is two signals, not one — the discipline `check_examples.py` already applies, for the same reason. An exit code alone accepts two measured failures. Every spec names its entry point rather than relying on `vera run`'s first-export fallback, because a `main` that is privatised or renamed otherwise runs *some other function* at exit 0. And the three examples that reach outside the process — `sqlitedb.vera` for its committed fixture, `database.vera` for an in-memory database, `file_io.vera` for the filesystem — answer a failure by printing a message and completing normally, so each pins a substring only its success path prints. Deleting `examples/sqlitedb.sqlite` fails the gate on that sentinel rather than passing on the graceful in-memory arm. + | Example | Executed by | Harness gate | |---------|-------------|--------------| | `absolute_value.vera` | browser parity (return value); `test_codegen_infrastructure.py` pins three results | runs | @@ -966,7 +968,7 @@ Twenty-nine scripts in `scripts/` validate cross-cutting concerns beyond unit te | `check_diagnostic_fields.py` | Every diagnostic in `vera/` carries rationale + spec_ref, and errors also a `fix` (warnings exempt); every present spec_ref resolves to a real spec section; every literal `error_code` is registered in `ERROR_CODES` (#828); `# diag-fields-exempt: ` waives missing/unresolvable fields only — never a wrong-but-resolving spec_ref or an unregistered error_code (#682, #955) | | `check_explicit_encoding.py` | Every text-mode `open()` / `read_text()` / `write_text()`, `subprocess.run/Popen/check_output` text capture, and text-mode `tempfile.NamedTemporaryFile` under `vera/`, `scripts/` and `tests/` passes an explicit `encoding="utf-8"`; `# encoding-exempt: ` opts a deliberate non-UTF-8 site out (#645) | | `check_e602_clean.py` | No unexpected E602/E604 silent-skip sites outside the explicit allowlist | -| `check_examples_run.py` | Every `examples/*.vera` either runs trap-free under the native runtime or carries a documented skip property; an unclassified example is an error, and TESTING.md's execution-coverage table must match the script's own classification | +| `check_examples_run.py` | Every `examples/*.vera` either runs trap-free under the native runtime or carries a documented skip property. Two signals, as in `check_examples.py`: the exit code, and an output signal — every spec names its entry point (so a privatised or renamed `main` exits 1 instead of silently running another export) and the three environment-dependent examples pin a success sentinel (so a vanished fixture fails rather than passing on a graceful arm). An unclassified example is an error, and TESTING.md's execution-coverage table must match the script's own classification | | `check_doc_builtin_shadowing.py` | No documentation example defines a function named after an opaque verifier-modelled built-in (would fail `vera check` with E151); the `spec/09` signature reference is exempt ([#819](https://github.com/aallan/vera/issues/819)) | | `check_grammar_alignment.py` | Every rule header in `spec/10-grammar.md`'s EBNF has a same-named rule in `vera/grammar.lark`, and the reverse. Names only — rule bodies are not compared ([#683](https://github.com/aallan/vera/issues/683)) | | `check_editor_grammars.py` | Every editor grammar under `editors/` (vscode, TextMate, Vim), and the two extension READMEs that repeat the list in prose, carries every built-in effect name from the live registry — read from the checked-out tree, not from whatever `vera` is importable. Word-boundary presence: absence is conclusive, presence is optimistic — the observed failure is omission. A completeness guard fails any grammar discovered under `editors/` that the checked list doesn't name ([#1156](https://github.com/aallan/vera/issues/1156)) | @@ -1069,7 +1071,7 @@ The repository configures 36 hooks across two stages: 34 run at the commit stage | `check_grammar_alignment.py` | Spec EBNF and Lark grammar agree on every rule name ([#683](https://github.com/aallan/vera/issues/683)) | | `check_editor_grammars.py` | Every editor grammar under `editors/`, and the two extension READMEs, carry every built-in effect name from the live registry ([#1156](https://github.com/aallan/vera/issues/1156)) | | `check_e602_clean.py` | No unexpected `[E602]` (body unsupported) / `[E604]` (param unsupported) silent skips outside the explicit allowlist (Layer 1 of [#626](https://github.com/aallan/vera/issues/626)) | -| `check_examples_run.py` | Every example runs trap-free or carries a documented skip property, and TESTING.md's execution-coverage table matches | +| `check_examples_run.py` | Every example runs trap-free (exit code plus an output signal) or carries a documented skip property, and TESTING.md's execution-coverage table matches | | `check_doc_counts.py` | Counts in docs match live codebase | | `check_walker_coverage.py` | Every walker function covers every `Expr` subclass via dispatch or checklist comment (#597) | | `check_diagnostic_fields.py` | Every diagnostic in `vera/` carries rationale + spec_ref, and errors also a `fix` (warnings exempt); every present spec_ref resolves to a real spec section; every literal `error_code` is registered in `ERROR_CODES` (#828); `# diag-fields-exempt: ` waives missing/unresolvable fields only — never a wrong-but-resolving spec_ref or an unregistered error_code (#682, #955) | diff --git a/scripts/check_examples_run.py b/scripts/check_examples_run.py index 716beb55..4a300173 100644 --- a/scripts/check_examples_run.py +++ b/scripts/check_examples_run.py @@ -38,6 +38,19 @@ score line against a mocked provider, and ``tests/test_browser.py`` pins 21 examples against the browser runtime. A gate that re-pinned stdout would duplicate those and go red on every cosmetic edit to an example. + +But *green* is two signals, not one, for the reason +`scripts/check_examples.py` gives where it asserts an exit code and an +``OK:`` sentinel together: either alone can be satisfied by the wrong +thing, and here both failures were measured rather than imagined. A +`main` that is privatised or renamed sends `vera run` to its first-export +fallback, which runs a different function and exits 0 — so every spec +names its entry point and the CLI resolves it by name. An example that +reaches outside the process answers a missing resource by printing a +message and completing normally — so the three that do carry an +``expect`` substring only their success path prints, which is what makes +deleting `examples/sqlitedb.sqlite` fail the gate instead of passing on +the graceful in-memory arm. Both signals, per run, always. """ from __future__ import annotations @@ -57,12 +70,21 @@ class RunSpec(NamedTuple): - """How to invoke one example under `vera run`. - - ``fn`` is ``None`` for a program with `main`. For a no-main example - `vera run` falls back to the *first export*, which is generally an - arbitrary function that needs arguments, so those must pin an entry - point explicitly. + """How to invoke one example under `vera run`, and what its success + looks like. + + ``fn`` is always named, never left implicit. With no ``--fn``, + `vera run` falls back to the *first export*, and that fallback is a + silent pass waiting to happen: privatise or rename `main` and the + gate runs some other function, at exit 0. Naming the entry point + makes the CLI resolve it and exit 1 when it is gone. + + ``expect`` is a substring the success path prints. It is set only + for the examples that reach outside the process and answer a failure + by printing a message and completing normally — for those, exit code + alone cannot tell the success path from the graceful one. It is + deliberately not a full stdout pin; that belongs in the dedicated + tests. A ``NamedTuple`` rather than a frozen dataclass so the module can be loaded by the bare ``spec_from_file_location`` / ``exec_module`` @@ -71,9 +93,17 @@ class RunSpec(NamedTuple): recipe leaves unregistered. """ - fn: str | None = None + fn: str = "main" args: tuple[str, ...] = () needs_db_fixture: bool = False + expect: str | None = None + + +# The line `vera run` prints when it cannot use the entry point it was +# given and falls back to the first export. Every spec names its entry +# point, so this note appearing at all means the resolution did not +# happen and some other function ran in its place. +FALLBACK_NOTE = "no 'main' declared" # Every skip property, with the reason it excludes harness execution. @@ -115,10 +145,15 @@ class RunSpec(NamedTuple): "base64": RunSpec(), "closures": RunSpec(fn="test_closure"), "collections": RunSpec(), - "database": RunSpec(), + # Reaches a real (in-memory) database and prints its error on the Err + # arm before completing normally, so exit 0 alone does not mean the + # round trip happened. + "database": RunSpec(expect="database round-trip succeeded"), "effect_handler": RunSpec(), "factorial": RunSpec(fn="factorial", args=("10",)), - "file_io": RunSpec(), + # Writes then reads back a file; a failed write prints the error and + # completes normally, so the sentinel is what proves the round trip. + "file_io": RunSpec(expect="Hello from Vera!"), "fizzbuzz": RunSpec(), "gc_pressure": RunSpec(), "generics": RunSpec(fn="test_generics"), @@ -141,8 +176,13 @@ class RunSpec(NamedTuple): # The committed on-disk fixture, threaded the way # `tests/test_db_runtime.py::TestDbOnDiskExample229` threads it — without # it the example takes its graceful in-memory `Err` arm and the on-disk - # read, which is the whole point of the example, never executes. - "sqlitedb": RunSpec(needs_db_fixture=True), + # read, which is the whole point of the example, never executes. That + # arm exits 0, so the sentinel is what makes deleting the fixture fail + # the gate rather than pass it. + "sqlitedb": RunSpec( + needs_db_fixture=True, + expect="read 4 cities from the on-disk database:", + ), "string_ops": RunSpec(), "string_utilities": RunSpec(fn="padded_id"), "url_encoding": RunSpec(), @@ -264,15 +304,44 @@ def check_coverage( def build_command(python: str, vera_file: Path, spec: RunSpec) -> list[str]: - """The `vera run` argv for one example.""" - cmd = [python, "-m", "vera.cli", "run", str(vera_file)] - if spec.fn is not None: - cmd += ["--fn", spec.fn] + """The `vera run` argv for one example. + + ``--fn`` is always passed — see ``RunSpec`` for why the implicit + first-export fallback is not safe to rely on. + """ + cmd = [python, "-m", "vera.cli", "run", str(vera_file), "--fn", spec.fn] if spec.args: cmd += ["--", *spec.args] return cmd +def check_output(name: str, spec: RunSpec, output: str) -> str | None: + """The second signal, beside the exit code: a failure line, or None. + + `scripts/check_examples.py` established the discipline — assert the + exit code AND an output signal, because either alone can be satisfied + by the wrong thing. Two measured cases motivate it here, and both + exit 0: a `main` that stops being callable, where `vera run` falls + back to an arbitrary export, and an external fixture that vanishes, + where the example takes its graceful arm. + """ + if FALLBACK_NOTE in output: + return ( + f"{name}: `vera run` could not use the entry point " + f"{spec.fn!r} and fell back to the first export — some other " + f"function ran, at exit 0. Usually the entry point was " + f"renamed or made private." + ) + if spec.expect is not None and spec.expect not in output: + return ( + f"{name}: exited 0 but its output does not contain " + f"{spec.expect!r} — the program completed down a graceful " + f"failure arm rather than its success path. Check whether " + f"the fixture or resource it needs is still there." + ) + return None + + def spec_env(spec: RunSpec, examples_dir: Path) -> dict[str, str]: """The environment a spec adds on top of the inherited one.""" if not spec.needs_db_fixture: @@ -329,9 +398,11 @@ def run_corpus( text=True, encoding="utf-8", cwd=str(workdir), - # Nothing may consume the invoking terminal's stdin: an - # example that read it would hang the hook, and the ones - # that do are skipped for exactly that reason. + # Nothing reads the invoking terminal's stdin: an example + # that tried would otherwise consume the user's keystrokes + # mid-hook. DEVNULL is an immediate EOF, not a hang — + # which is why terminal-dependence rather than hanging is + # the reason the two stdin examples are skipped. stdin=subprocess.DEVNULL, env=build_env(spec, examples_dir), timeout=TIMEOUT_SECONDS, @@ -351,6 +422,13 @@ def run_corpus( f"{name}: `vera run` exited {result.returncode}: " f"{detail[:300]}" ) + continue + + # Exit code clean — now the second signal. Both streams: the + # fallback note goes to stderr, the sentinels to stdout. + message = check_output(name, spec, result.stdout + result.stderr) + if message is not None: + failures.append(message) return failures @@ -435,6 +513,52 @@ def check_testing_md( return errors +# --------------------------------------------------------------------------- +# Reporting +# --------------------------------------------------------------------------- + + +def _block(title: str, errors: list[str], footer: str = "") -> list[str]: + if not errors: + return [] + lines = [f"{title} ({len(errors)}):"] + lines += [f" {e}" for e in errors] + if footer: + lines += ["", footer] + return lines + + +def error_blocks( + coverage_errors: list[str], + doc_errors: list[str], + failures: list[str], +) -> list[str]: + """The stderr report, as labelled blocks. + + Each kind gets its own header carrying its own count. Filing one + kind's lines under another's header misreports both — a reader who + counts the lines beneath `COVERAGE ERRORS (n)` gets a number the + header disagrees with. + """ + return [ + *_block("COVERAGE ERRORS", coverage_errors), + *_block( + "DOCUMENTATION MISMATCH", doc_errors, + "TESTING.md's execution-model table is the documented form of " + "the classification in this script. Update the table to " + "match, so the model stays readable without reading the " + "source.", + ), + *_block( + "RUNTIME FAILURES", failures, + "An example that no longer runs is a bug in the compiler or " + "in the example, not something to suppress: SKIPS is for " + "programs the harness structurally cannot drive, not for ones " + "that are broken.", + ), + ] + + # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- @@ -458,13 +582,8 @@ def main() -> int: # The coverage rule gates the run: with the tables out of sync with # disk, a green run would be reporting on the wrong set of programs. if coverage_errors: - print( - f"COVERAGE ERRORS ({len(coverage_errors)}):", file=sys.stderr - ) - for e in coverage_errors: - print(f" {e}", file=sys.stderr) - for e in doc_errors: - print(f" {e}", file=sys.stderr) + for line in error_blocks(coverage_errors, doc_errors, []): + print(line, file=sys.stderr) return 1 with tempfile.TemporaryDirectory(prefix="vera-examples-run-") as td: @@ -480,37 +599,11 @@ def main() -> int: print(f" skip [{prop}]: {', '.join(skipped)}") print(f" {SKIP_PROPERTIES[prop]}") - if doc_errors: - print( - f"\nDOCUMENTATION MISMATCH ({len(doc_errors)}):", file=sys.stderr - ) - for e in doc_errors: - print(f" {e}", file=sys.stderr) - print( - "\nTESTING.md's execution-model table is the documented form " - "of the classification in this script. Update the table to " - "match, so the model stays readable without reading the " - "source.", - file=sys.stderr, - ) - - if failures: - print( - f"\nRUNTIME FAILURES ({len(failures)} example(s) that pass " - f"check, verify and compile but do not run):", - file=sys.stderr, - ) - for f in failures: - print(f" {f}", file=sys.stderr) - print( - "\nAn example that no longer runs is a bug in the compiler or " - "in the example, not something to suppress: SKIPS is for " - "programs the harness structurally cannot drive, not for ones " - "that are broken.", - file=sys.stderr, - ) - - if doc_errors or failures: + blocks = error_blocks([], doc_errors, failures) + if blocks: + print("", file=sys.stderr) + for line in blocks: + print(line, file=sys.stderr) return 1 print("\nAll runnable examples execute trap-free.") diff --git a/tests/test_check_examples_run.py b/tests/test_check_examples_run.py index 55959b43..6c21610b 100644 --- a/tests/test_check_examples_run.py +++ b/tests/test_check_examples_run.py @@ -26,6 +26,7 @@ from __future__ import annotations import importlib.util +import re from pathlib import Path from typing import Any @@ -117,7 +118,13 @@ def test_unclassified_example_is_an_error(self) -> None: def test_stale_run_spec_is_an_error(self) -> None: """A RUN_SPECS key whose file was deleted or renamed is an error — - otherwise the entry silently stops covering anything.""" + otherwise the entry silently stops covering anything. + + The message must name the table it came from: both stale branches + report the same example-name shape, so asserting the name alone + passes whichever fired and cannot tell a stale run spec from a + stale skip. + """ errors = _MOD.check_coverage( ["known"], {"known": _MOD.RunSpec(), "deleted": _MOD.RunSpec()}, @@ -125,6 +132,8 @@ def test_stale_run_spec_is_an_error(self) -> None: ) assert len(errors) == 1 assert "deleted" in errors[0] + assert "RUN_SPECS" in errors[0] + assert "SKIPS" not in errors[0] def test_stale_skip_is_an_error(self) -> None: """The same for a skip: a suppression outliving its example would @@ -134,6 +143,8 @@ def test_stale_skip_is_an_error(self) -> None: ) assert len(errors) == 1 assert "gone" in errors[0] + assert "SKIPS" in errors[0] + assert "RUN_SPECS" not in errors[0] def test_example_in_both_tables_is_an_error(self) -> None: """Ambiguous classification: which one wins is not a question the @@ -183,8 +194,6 @@ class TestRunSpecsAreWellFormed: def test_every_named_fn_is_public_in_its_example(self) -> None: bad = [] for name, spec in _MOD.RUN_SPECS.items(): - if spec.fn is None: - continue src = (_ROOT / "examples" / f"{name}.vera").read_text( encoding="utf-8" ) @@ -192,19 +201,25 @@ def test_every_named_fn_is_public_in_its_example(self) -> None: bad.append(f"{name}: no `public fn {spec.fn}`") assert bad == [] - def test_examples_without_main_carry_an_explicit_entry_point(self) -> None: - """`vera run` with no `--fn` falls back to the first export, which - for a no-main example is an arbitrary function that usually needs - arguments. Every runnable no-main example must therefore pin its - entry point.""" - bad = [] - for name, spec in _MOD.RUN_SPECS.items(): - src = (_ROOT / "examples" / f"{name}.vera").read_text( - encoding="utf-8" - ) - if "public fn main(" not in src and spec.fn is None: - bad.append(name) - assert bad == [] + def test_no_spec_relies_on_the_first_export_fallback(self) -> None: + """`vera run` with no `--fn` falls back to the *first export*, and + that fallback is a silent pass waiting to happen: privatise or + rename `main` and the gate runs some other function at exit 0. + Every spec names its entry point, so the CLI resolves it by name + and exits 1 when it is gone.""" + assert all(spec.fn for spec in _MOD.RUN_SPECS.values()) + + def test_environment_dependent_specs_carry_an_output_sentinel( + self, + ) -> None: + """Three examples reach outside the process — a committed SQLite + fixture, an in-memory database, the filesystem — and each answers a + failure by printing a message and completing normally. Exit code + alone cannot tell their success path from their graceful one, so + each must pin a substring only the success path prints.""" + for name in ("sqlitedb", "database", "file_io"): + spec = _MOD.RUN_SPECS[name] + assert spec.expect, f"{name} has no expected-output sentinel" def test_every_skip_property_is_documented(self) -> None: for name, prop in _MOD.SKIPS.items(): @@ -217,10 +232,12 @@ def test_no_unused_skip_property(self) -> None: class TestBuildCommand: - def test_main_entry_point_passes_no_fn_flag(self) -> None: + def test_default_spec_names_main_explicitly(self) -> None: + """No spec leaves the entry point implicit — see + `test_no_spec_relies_on_the_first_export_fallback`.""" cmd = _MOD.build_command("py", Path("/x/a.vera"), _MOD.RunSpec()) - assert "--fn" not in cmd assert cmd[:4] == ["py", "-m", "vera.cli", "run"] + assert cmd[-2:] == ["--fn", "main"] def test_named_entry_point_and_args(self) -> None: cmd = _MOD.build_command( @@ -284,6 +301,76 @@ def test_every_neutralised_name_is_actually_read_by_an_example( assert name in blob, name +class TestOutputSignals: + """Exit code is not enough, and the sibling gate already says so. + + `scripts/check_examples.py` asserts the exit code *and* an output + sentinel, because either alone can be satisfied by the wrong thing. + Here the two measured cases are a `main` that stops being callable — + where `vera run` falls back to an arbitrary export and exits 0 — and + an external fixture that vanishes, where the example takes its + graceful arm and exits 0. + """ + + def test_clean_output_passes(self) -> None: + assert _MOD.check_output("a", _MOD.RunSpec(), "all good") is None + + def test_fallback_note_is_a_failure_at_exit_zero(self) -> None: + """`vera run` prints this note when it cannot use the named entry + point and picks the first export instead. Every spec names its + entry point, so seeing the note means the resolution did not + happen — a run of some other function reported as a pass.""" + msg = _MOD.check_output( + "a", _MOD.RunSpec(), + "Note: no 'main' declared — running public function 'other'.\n7", + ) + assert msg is not None + assert "a" in msg + assert "first export" in msg + + def test_missing_sentinel_is_a_failure(self) -> None: + msg = _MOD.check_output( + "sqlitedb", _MOD.RunSpec(expect="read 4 cities"), + "no cities table — run with VERA_DB_URL=...", + ) + assert msg is not None + assert "read 4 cities" in msg + + def test_present_sentinel_passes(self) -> None: + assert _MOD.check_output( + "sqlitedb", _MOD.RunSpec(expect="read 4 cities"), + "read 4 cities from the on-disk database:\nLondon | UK", + ) is None + + def test_no_sentinel_means_no_output_assertion(self) -> None: + """Specs without an `expect` are asserted on exit code alone — the + gate does not re-pin stdout that the dedicated tests own.""" + assert _MOD.check_output("a", _MOD.RunSpec(), "anything at all") is None + + def test_the_runner_hands_both_streams_to_the_output_check(self) -> None: + """A structural pin, because the behaviour it protects is currently + unreachable and that is exactly why it needs one. + + `vera run` writes the fallback note to **stderr** and emits nothing + there on a clean exit, so with `--fn` always passed there is no + program that can make the note appear at exit 0 — no end-to-end + fixture can distinguish a runner that reads both streams from one + that reads only stdout. The guard is a tripwire for the day + `--fn` stops being honoured, and a tripwire wired to the wrong + stream is no tripwire at all. Pinning the call shape keeps it + armed; the same technique `tests/test_verifier_refinements.py` + uses for its Tier-3 disclosure sites. + """ + import inspect + + src = inspect.getsource(_MOD.run_corpus) + call = re.search(r"check_output\(([^)]*)\)", src) + assert call is not None, "run_corpus no longer calls check_output" + args = call.group(1) + assert "result.stdout" in args, args + assert "result.stderr" in args, args + + # --------------------------------------------------------------------------- # The runner # --------------------------------------------------------------------------- @@ -320,9 +407,10 @@ def test_mixed_corpus_reports_only_the_broken_one( def test_named_entry_point_is_actually_invoked( self, tmp_path: Path ) -> None: - """A runner that ignored ``spec.fn`` would call the first export and - pass. Here the first export is clean and the named one traps, so - only a runner that honours the spec goes red.""" + """A runner that ignored ``spec.fn`` would run the same thing for + both specs below. One entry point is clean and the other traps, so + only a runner that honours the spec can be green for one and red for + the other.""" src = """\ public fn first(-> @Int) requires(true) @@ -342,12 +430,27 @@ def test_named_entry_point_is_actually_invoked( } """ d = _corpus(tmp_path, {"pick": src}) - assert _MOD.run_corpus(d, {"pick": _MOD.RunSpec()}, tmp_path) == [] + assert _MOD.run_corpus( + d, {"pick": _MOD.RunSpec(fn="first")}, tmp_path + ) == [] failures = _MOD.run_corpus( d, {"pick": _MOD.RunSpec(fn="second")}, tmp_path ) assert len(failures) == 1 + def test_a_renamed_entry_point_fails_rather_than_falling_back( + self, tmp_path: Path + ) -> None: + """The other half of the privatised-main case: a spec naming an + entry point the example no longer has must exit 1 on the name, + never quietly run whatever export happens to be first.""" + d = _corpus(tmp_path, {"pick": _CLEAN_SRC}) + failures = _MOD.run_corpus( + d, {"pick": _MOD.RunSpec(fn="renamed_away")}, tmp_path + ) + assert len(failures) == 1 + assert "renamed_away" in failures[0] + def test_runner_does_not_write_into_the_corpus( self, tmp_path: Path ) -> None: @@ -373,6 +476,60 @@ def test_runner_does_not_write_into_the_corpus( assert _MOD.run_corpus(d, {"writer": _MOD.RunSpec()}, tmp_path) == [] assert sorted(p.name for p in d.iterdir()) == before + def test_privatised_main_fails_the_gate(self, tmp_path: Path) -> None: + """The measured silent pass: with `main` no longer callable, + `vera run` picks the first export and exits 0. Reproduced here + with a second export that succeeds, so exit code alone cannot + distinguish it — only naming the entry point, or catching the + fallback note, goes red.""" + src = """\ +private fn main(-> @Int) + requires(true) + ensures(true) + effects(pure) +{ + 1 +} + +public fn other(-> @Int) + requires(true) + ensures(true) + effects(pure) +{ + 2 +} +""" + d = _corpus(tmp_path, {"hidden": src}) + failures = _MOD.run_corpus(d, {"hidden": _MOD.RunSpec()}, tmp_path) + assert len(failures) == 1 + assert "hidden" in failures[0] + + def test_absent_sentinel_fails_the_gate_at_exit_zero( + self, tmp_path: Path + ) -> None: + """The fixture-vanished shape: the program completes normally and + exits 0 down its graceful arm, printing something other than what + the success path prints.""" + src = """\ +public fn main(-> @Unit) + requires(true) + ensures(true) + effects() +{ + IO.print("took the graceful arm") +} +""" + d = _corpus(tmp_path, {"degraded": src}) + spec = _MOD.RunSpec(expect="read 4 cities") + failures = _MOD.run_corpus(d, {"degraded": spec}, tmp_path) + assert len(failures) == 1 + assert "read 4 cities" in failures[0] + # And the same program passes once its own output is the sentinel, + # so the check is reading the output rather than always failing. + assert _MOD.run_corpus( + d, {"degraded": _MOD.RunSpec(expect="graceful arm")}, tmp_path + ) == [] + def test_missing_file_for_a_spec_is_a_failure( self, tmp_path: Path ) -> None: @@ -495,6 +652,39 @@ def test_heading_present_but_table_empty_is_an_error(self) -> None: assert len(errors) == 1 assert "no rows" in errors[0].lower() + def test_doc_errors_are_never_filed_under_the_coverage_count( + self, + ) -> None: + """Two error kinds, two labelled blocks, each counting its own. + Printing documentation mismatches beneath a `COVERAGE ERRORS (n)` + header whose n excludes them misreports both — the reader counts + the lines and gets a different number from the one on the header. + """ + blocks = _MOD.error_blocks( + coverage_errors=["one coverage problem"], + doc_errors=["one doc problem", "another doc problem"], + failures=[], + ) + text = "\n".join(blocks) + assert "COVERAGE ERRORS (1)" in text + assert "DOCUMENTATION MISMATCH (2)" in text + assert "one doc problem" in text + # Every reported line sits under a header, and every header's count + # matches the lines beneath it. + counted = { + int(m) for m in re.findall(r"\((\d+)\)", text) + } + assert counted == {1, 2} + + def test_error_blocks_are_empty_when_nothing_is_wrong(self) -> None: + assert _MOD.error_blocks([], [], []) == [] + + def test_runtime_failures_get_their_own_counted_block(self) -> None: + blocks = _MOD.error_blocks([], [], ["boom: exited 1"]) + text = "\n".join(blocks) + assert "RUNTIME FAILURES (1)" in text + assert "boom: exited 1" in text + def test_table_parse_stops_at_the_next_heading(self) -> None: """A row-shaped line in a later section must not be swept in as an example row — the parse is scoped to the subsection.""" From e20e99d810f7d3f6b70ec841a48eab06c5cdeab6 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 13:40:08 +0100 Subject: [PATCH 35/69] Stop the gate creating the fixture it checks for, and close seven review notes sqlite3 CREATES the database named by a `sqlite:///` URL when it is not there, so with examples/sqlitedb.sqlite deleted the gate handed the example a URL for the absent file and an empty 0-byte database appeared in examples/. The sentinel still failed the run -- right verdict -- but the gate had written into the corpus it was checking, which is the one thing the per-run scratch directory exists to prevent. `missing_fixture` now checks before the process starts, so the run never happens: measured with the fixture deleted, the gate fails naming the path and creates nothing. Six smaller notes alongside. `all(spec.fn for ...)` was vacuous -- `fn` defaults to "main", so only a deliberate `fn=""` could ever fail it, and the cell restated the default instead of testing the property. It now asserts the built argv carries `--fn` for every spec, and the stop-passing---fn mutant dies through this cell as well as the two that already caught it. The TimeoutExpired branch was unreached. A purpose-built sleeper with the budget monkeypatched down drives it -- not life.vera, which is skipped as long-running and would have to be un-skipped to reach it -- and asserts the distinct timeout wording rather than merely a failure. parse_testing_table treated any `#` at column 0 as the end of the subsection, including the 32 such lines TESTING.md carries inside fenced blocks. None sits between the heading and the table today, so the failure was latent rather than live: an ordinary annotated code block added above the table would have emptied it and failed the gate on a well-formed document. The parse now tracks fences, with the complement pinned so the guard cannot degenerate into never terminating. TESTING.md said "Five layers ... only the last two execute" over a table of six rows whose last three execute. CLAUDE.md's line claimed the gate runs every example when it runs the runnable ones and classifies the rest. CONTRIBUTING.md carried 33 commit-stage hooks two lines below the 34 corrected earlier; the live config has 36 total, 2 push-stage, and the file has exactly three hook numerals, now all consistent. And the three error_blocks tests move out of the TESTING.md cross-check class into their own, matching the module's section structure. Co-Authored-By: Claude --- CLAUDE.md | 2 +- CONTRIBUTING.md | 2 +- TESTING.md | 4 +- scripts/check_examples_run.py | 56 +++++++- tests/test_check_examples_run.py | 218 ++++++++++++++++++++++++++----- 5 files changed, 243 insertions(+), 39 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 20b085e4..193618e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,7 +60,7 @@ mypy vera/ # Type-check the compiler itself python scripts/check_conformance.py # Verify all 229 conformance programs (positives pass their level; negatives fail with their expected_error E-code) python scripts/check_examples.py # Verify all 42 examples parse + check + verify -python scripts/check_examples_run.py # Run every example trap-free under the native runtime; an example that is neither run nor matched to a documented skip property is an error +python scripts/check_examples_run.py # Run every runnable example trap-free under the native runtime; the rest carry a documented skip property, and an example that is neither is an error python scripts/check_corpus_canonical.py # Verify all 278 corpus programs are in canonical form (vera fmt) python scripts/check_examples_readme.py # Verify vera run commands in examples/README.md python scripts/check_spec_examples.py # Verify spec code blocks parse diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b60e7fa6..f4096294 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -93,7 +93,7 @@ The repository configures 36 hooks across two stages: 34 run at the commit stage ![The gate pipeline: file-filtered commit-stage hooks, the push-stage CHANGELOG and uv.lock gates, and CI re-running everything against the platform matrix before anything lands on protected main.](assets/diagrams/ci-gates.svg) -The **commit-stage** hooks (33, each gated to relevant files) include: +The **commit-stage** hooks (34, each gated to relevant files) include: - Trailing whitespace and file endings - YAML/TOML validity diff --git a/TESTING.md b/TESTING.md index 56ace2d3..42cb5ce6 100644 --- a/TESTING.md +++ b/TESTING.md @@ -215,7 +215,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_release.py` | 47 | 566 | Release policy and registry verification (#481): strict project-name and version parsing/comparison, version-bump/TestPyPI/recovery planning, exact confirmation and immutable-tag guards, first-parent version-introduction discovery, package-change recovery refusal, non-empty CHANGELOG extraction, one-wheel/one-sdist SHA-256 manifests, malformed registry-response handling, missing/filename/hash propagation retries, exact filename/hash verification, and CLI dispatch/GitHub-output wiring. An autouse fixture scrubs hook-exported `GIT_*` variables so the tmp-repo git calls (fixture helpers and `release.py`'s own) never resolve to the developer's repository when the suite runs inside a pre-commit hook. | | `test_check_doc_counts.py` | 54 | 665 | `check_doc_counts.py`'s pure per-document checks: KNOWN_ISSUES refactoring line counts (±10% tolerance band incl. the exact-boundary case, drift detection, empty-file citation, hyphenated paths, missing file/section/rows, the #419 empty-section sentinel + its cannot-mask-a-malformed-table dual), HISTORY version-row format (issue-link limit, ` — ` separator rejection, dateless-row and prose exemption, line-number reporting), the TESTING.md tests breakdown (parts summing to the collected total, a self-consistent-but-stale row, and the reworded-row error), and vera/README.md's Test Suite counts (all four checked independently — mutation-validated by dropping each citation in turn — plus the reworded-paragraph error, a thousands-separator case pinning that every one of the four counts is read comma-tolerantly, and the two section-anchoring cases — a reworded paragraph with decoy counts in a later section, and a renamed heading, both of which must fail loud rather than match across the section boundary). The reworded case is a test in its own right for both new checks: a pattern that matches nothing must be an error, or rewording the sentence silently switches the gate off. Also the release count (README's status line and HISTORY's total against each other and against `git tag`: the matching case, the one-ahead release cut that `release.yml` has not tagged yet, that +1 being the ONLY slack once the version is tagged, the two-behind drift that actually shipped, per-document reporting, and a tagless checkout standing the oracle down without standing down the cross-check), plus the tag reader itself against real repositories built in `tmp_path` — release tags read, `nightly`/`-rc1` not counted as releases, and both no-evidence answers (`None` rather than `[]`, since an empty list would read as zero releases and make every documented count wrong) for a tagless checkout and a directory that is not a repository at all. CONTRIBUTING.md's pre-commit hook count is checked the same way, reworded-sentence case included; and the CI-pipeline lint row against `ci.yml`'s lint job — a matching row, a step present in CI but absent from the row (the drift that shipped), a row entry CI no longer runs, the same set in a different order, a reworded row and a renamed job (both errors, not skips), that only the lint job is read rather than the whole workflow, and the shipped pair both clean and red with one entry dropped | | `test_grammar_alignment.py` | 44 | 309 | `check_grammar_alignment.py` gate ([#683](https://github.com/aallan/vera/issues/683)): both extractors (Lark headers with the `?`/`!`/`_` markers stripped, template parameters and rule priorities tolerated, and `-> alias` names deliberately not collected; spec headers from ```ebnf fences only), the allowlist arithmetic in all three directions — unwaived drift, a spent entry both files now have, and an entry whose premise broke — one case per waiver proving the fact it rests on is actually checked, the name-deleted-from-both-files case that must not read as agreement, a mutation restoring the spec's old `assert_stmt` name, non-vacuous extraction, and the false positive the issue itself rested on: `qualified_call` and `module_call` are spec headers Lark expresses as aliases, and must never be reported as drift. Three pin the premise checks against ways they used to pass vacuously: an alias surviving only inside a `//` comment must not hold its waiver up, an alias that moved to another rule must fail the waiver naming `fn_call`, and a spent waiver whose premise also broke must yield one instruction rather than two opposite ones | -| `test_check_examples_run.py` | 51 | 695 | `check_examples_run.py`, the harness gate that RUNS the examples. Four separable parts, each in both directions. **The coverage rule** — the shipped tables cover the shipped corpus exactly, and an unclassified example, a stale `RUN_SPECS` or `SKIPS` key whose file is gone, a name in both tables, and a skip citing an undocumented property are each an error; the empty corpus is an error too, since a glob that stops matching would otherwise report success over zero programs. Plus the specs' own well-formedness: every named entry point is `public` in its example, every no-main example pins one (or `vera run` would fall back to an arbitrary first export), and no skip property is unused. **The runner** — a seeded `tmp_path` corpus proves it goes red on a program that type-checks and compiles but traps at run time, green on one that does not, and reports only the broken member of a mixed pair; a fixture whose first export is clean and whose named one traps proves `spec.fn` is actually honoured rather than ignored; a writer program proves each run gets a scratch working directory, so a gate run leaves nothing beside the examples. **The TESTING.md cross-check** — missing row, extra row, rename (reported naming both sides), wrong disposition and wrong skip property are errors, the parse stops at the next heading so a row-shaped line in a later section is not swept in, and both a reworded heading and a heading whose table has vanished fail loud rather than finding nothing to compare. **The output signal** -- the second half of the two-signal discipline `check_examples.py` established: the fallback note that `vera run` prints when it cannot use the named entry point is a failure even at exit 0, an absent `expect` sentinel is a failure even at exit 0, and a spec without one asserts nothing about output; end to end, a privatised `main` and a program that completed down a graceful arm each go red, and the same program passes once its own output is the sentinel, so the check reads the output rather than always failing. The three environment-dependent specs are required to carry a sentinel, and the runner's use of BOTH streams is pinned structurally -- `vera run` writes the note to stderr and nothing there on a clean exit, so no fixture can distinguish reading both streams from reading stdout alone, and a tripwire wired to the wrong stream is no tripwire. Also the hermetic-environment property: an ambient `VERA_DB_URL` or provider key is stripped so a gate run cannot be pointed at a real database or turned into a billed API call, a fixture spec puts its own URL back, and every neutralised name is checked to be one `vera/runtime/` actually reads | +| `test_check_examples_run.py` | 59 | 853 | `check_examples_run.py`, the harness gate that RUNS the examples. Five separable parts, each in both directions. **The coverage rule** — the shipped tables cover the shipped corpus exactly, and an unclassified example, a stale `RUN_SPECS` or `SKIPS` key whose file is gone, a name in both tables, and a skip citing an undocumented property are each an error; the empty corpus is an error too, since a glob that stops matching would otherwise report success over zero programs. Plus the specs' own well-formedness: every named entry point is `public` in its example, every no-main example pins one (or `vera run` would fall back to an arbitrary first export), and no skip property is unused. **The runner** — a seeded `tmp_path` corpus proves it goes red on a program that type-checks and compiles but traps at run time, green on one that does not, and reports only the broken member of a mixed pair; a fixture whose first export is clean and whose named one traps proves `spec.fn` is actually honoured rather than ignored; a writer program proves each run gets a scratch working directory, so a gate run leaves nothing beside the examples. **The TESTING.md cross-check** — missing row, extra row, rename (reported naming both sides), wrong disposition and wrong skip property are errors, the parse stops at the next heading so a row-shaped line in a later section is not swept in, and both a reworded heading and a heading whose table has vanished fail loud rather than finding nothing to compare. **The output signal** -- the second half of the two-signal discipline `check_examples.py` established: the fallback note that `vera run` prints when it cannot use the named entry point is a failure even at exit 0, an absent `expect` sentinel is a failure even at exit 0, and a spec without one asserts nothing about output; end to end, a privatised `main` and a program that completed down a graceful arm each go red, and the same program passes once its own output is the sentinel, so the check reads the output rather than always failing. The three environment-dependent specs are required to carry a sentinel, and the runner's use of BOTH streams is pinned structurally -- `vera run` writes the note to stderr and nothing there on a clean exit, so no fixture can distinguish reading both streams from reading stdout alone, and a tripwire wired to the wrong stream is no tripwire. Also the hermetic-environment property: an ambient `VERA_DB_URL` or provider key is stripped so a gate run cannot be pointed at a real database or turned into a billed API call, a fixture spec puts its own URL back, and every neutralised name is checked to be one `vera/runtime/` actually reads | | `test_check_editor_grammars.py` | 20 | 249 | `check_editor_grammars.py` gate ([#1156](https://github.com/aallan/vera/issues/1156)): the registry read (every effect in, every ability out, and the four names the grammars actually drifted on present so the set checked is non-vacuous), the word-boundary presence test across all three grammar formats (JSON, plist XML, Vim keyword list) including the prefix pair `Http`/`HttpServer` in both directions, the deliberate comment-mention false pass, a metacharacter pair that only passes when the name is matched literally (`A.B` present, `A0B` not), and the empty registry; the gate's primary path end to end — a listed grammar with an effect stripped out, and a listed README with one stripped out of its prose bullet, each red against an otherwise-clean mirrored tree; the completeness guard — a grammar discovered under `editors/` but absent from `GRAMMARS` fails that same tree, over three discovery routes (`.el` outside a syntax directory, a tree-sitter `.scm` query set, a `.tmLanguage.json` filed anywhere but `syntaxes/`); and the registry's provenance, run as a subprocess against a throwaway checkout whose `vera` package names an effect the grammars do not, which is the only way to see that the list comes from the tree being checked rather than from site-packages. The shipped grammars and READMEs are currently clean | | `test_check_explicit_encoding.py` | 54 | 254 | `check_explicit_encoding.py` gate (#645): flags text-mode `open()` / `read_text()` / `write_text()` **and** `subprocess.run/Popen/check_output(..., text=True)` captures missing an `encoding="utf-8"` literal (rejects non-literal / non-UTF-8 values), skips binary/bytes-mode calls, honours the `# encoding-exempt` opt-out, and asserts the shipped repo is clean | | `test_check_limitations_sync.py` | 6 | 108 | `check_limitations_sync.py` section extraction: table-rows-only issue harvesting, prose-link exemption, bounding at the next second-level heading, `None` for absent or sub-level headings so renamed sections fail loudly; plus the #852 fail-loud rule: an UNKNOWN issue state under `--check-states` (gh missing / auth failure / timeout) is an error, never a silent pass | @@ -619,7 +619,7 @@ The formatter has **idempotency tests**: `format(format(x)) == format(x)` for al ### Example execution coverage -An example that parses, type-checks, verifies and compiles can still trap the instant it runs. Five layers cover `examples/`, and only the last two execute anything: +An example that parses, type-checks, verifies and compiles can still trap the instant it runs. Six layers cover `examples/`, and only the last three execute anything: | Layer | Mechanism | Reach | |-------|-----------|-------| diff --git a/scripts/check_examples_run.py b/scripts/check_examples_run.py index 4a300173..43debfc1 100644 --- a/scripts/check_examples_run.py +++ b/scripts/check_examples_run.py @@ -227,6 +227,9 @@ class RunSpec(NamedTuple): # reported as a failure rather than blocking the hook indefinitely. TIMEOUT_SECONDS = 300 +# The committed on-disk database `sqlitedb.vera` reads. +DB_FIXTURE = "sqlitedb.sqlite" + # --------------------------------------------------------------------------- # The coverage rule @@ -342,11 +345,28 @@ def check_output(name: str, spec: RunSpec, output: str) -> str | None: return None +def missing_fixture(spec: RunSpec, examples_dir: Path) -> Path | None: + """The committed fixture a spec needs but cannot find, if any. + + Checked *before* the run rather than left to fail inside it. + `sqlite3` CREATES the database named by a `sqlite:///` URL when it is + not there, so handing the example a URL for an absent fixture + materialises an empty `examples/sqlitedb.sqlite` as a side effect. + The sentinel would still fail the run — right verdict — but the gate + would have written into the corpus it is checking, which is the very + thing the per-run scratch directory exists to prevent. + """ + if not spec.needs_db_fixture: + return None + fixture = examples_dir / DB_FIXTURE + return None if fixture.is_file() else fixture + + def spec_env(spec: RunSpec, examples_dir: Path) -> dict[str, str]: """The environment a spec adds on top of the inherited one.""" if not spec.needs_db_fixture: return {} - fixture = (examples_dir / "sqlitedb.sqlite").resolve() + fixture = (examples_dir / DB_FIXTURE).resolve() # POSIX form so the URL is portable on Windows, matching # `tests/test_db_runtime.py`; `_open_connection` strips the prefix # back to the filesystem path. @@ -389,6 +409,15 @@ def run_corpus( ) continue + absent = missing_fixture(spec, examples_dir) + if absent is not None: + failures.append( + f"{name}: the committed fixture {absent} does not exist, " + f"so the run was not started — sqlite3 would have created " + f"an empty database there rather than reading one" + ) + continue + workdir = workdir_root / f"run-{name}" workdir.mkdir(parents=True, exist_ok=True) try: @@ -449,19 +478,36 @@ def parse_testing_table(text: str) -> dict[str, str] | None: and neither may be reported as a pass. """ lines = text.splitlines() + + # A `#` at column 0 inside a fenced block is a shell comment or a + # heading in sample Markdown, not a heading of this document — and + # TESTING.md has 32 such lines. Tracking fences keeps one from + # ending the subsection early, which would empty the table and fail + # the gate on a document that is perfectly well formed. + def _headings(seq: list[str]) -> list[bool]: + out, in_fence = [], False + for line in seq: + if line.lstrip().startswith("```"): + in_fence = not in_fence + out.append(False) + continue + out.append(not in_fence and line.startswith("#")) + return out + + is_heading = _headings(lines) start = None for i, line in enumerate(lines): - if line.startswith("#") and _TABLE_HEADING in line: + if is_heading[i] and _TABLE_HEADING in line: start = i break if start is None: return None rows: dict[str, str] = {} - for line in lines[start + 1:]: - if line.startswith("#"): # the next heading ends the subsection + for i in range(start + 1, len(lines)): + if is_heading[i]: # the next heading ends the subsection break - m = _ROW_RE.match(line) + m = _ROW_RE.match(lines[i]) if m: rows[m.group(1)] = m.group(2).strip() return rows diff --git a/tests/test_check_examples_run.py b/tests/test_check_examples_run.py index 6c21610b..a86f98d5 100644 --- a/tests/test_check_examples_run.py +++ b/tests/test_check_examples_run.py @@ -30,6 +30,8 @@ from pathlib import Path from typing import Any +import pytest + _SCRIPT = Path(__file__).parent.parent / "scripts" / "check_examples_run.py" _ROOT = Path(__file__).parent.parent @@ -206,8 +208,20 @@ def test_no_spec_relies_on_the_first_export_fallback(self) -> None: that fallback is a silent pass waiting to happen: privatise or rename `main` and the gate runs some other function at exit 0. Every spec names its entry point, so the CLI resolves it by name - and exits 1 when it is gone.""" - assert all(spec.fn for spec in _MOD.RUN_SPECS.values()) + and exits 1 when it is gone. + + Asserted on the built argv rather than on ``spec.fn`` being + truthy: ``fn`` defaults to ``"main"``, so a truthiness check + passes for every spec that could ever exist short of a + deliberate ``fn=""`` — it restates the default instead of + testing the property, which is that ``--fn`` reaches the CLI. + """ + for name, spec in _MOD.RUN_SPECS.items(): + cmd = _MOD.build_command( + "py", _ROOT / "examples" / f"{name}.vera", spec + ) + assert "--fn" in cmd, name + assert cmd[cmd.index("--fn") + 1] == spec.fn, name def test_environment_dependent_specs_carry_an_output_sentinel( self, @@ -256,6 +270,64 @@ def test_no_db_fixture_adds_no_env(self) -> None: assert _MOD.spec_env(_MOD.RunSpec(), _ROOT / "examples") == {} +class TestFixturePrecondition: + """A missing fixture must stop the run, not be papered over by it. + + `sqlite3` CREATES the database file named by a `sqlite:///` URL when + it is not there. Handing the example a URL for an absent fixture + therefore materialises an empty `examples/sqlitedb.sqlite` as a side + effect of the gate — the sentinel still fails the run, so the verdict + is right, but the gate has written into the corpus it is checking, + which is precisely what the per-run scratch directory exists to + prevent. The fixture is checked before the process starts. + """ + + def test_present_fixture_is_not_reported(self) -> None: + assert _MOD.missing_fixture( + _MOD.RunSpec(needs_db_fixture=True), _ROOT / "examples" + ) is None + + def test_absent_fixture_is_reported_by_path(self, tmp_path: Path) -> None: + missing = _MOD.missing_fixture( + _MOD.RunSpec(needs_db_fixture=True), tmp_path + ) + assert missing is not None + assert missing.name == "sqlitedb.sqlite" + + def test_specs_that_need_no_fixture_are_not_reported( + self, tmp_path: Path + ) -> None: + assert _MOD.missing_fixture(_MOD.RunSpec(), tmp_path) is None + + def test_absent_fixture_fails_the_gate_and_creates_nothing( + self, tmp_path: Path + ) -> None: + """The proving test, on the real example: the run must fail + naming the fixture, and must leave no file behind where the + fixture would have been.""" + d = tmp_path / "examples" + d.mkdir() + (d / "sqlitedb.vera").write_text( + (_ROOT / "examples" / "sqlitedb.vera").read_text( + encoding="utf-8" + ), + encoding="utf-8", + ) + fixture = d / "sqlitedb.sqlite" + assert not fixture.exists() + + failures = _MOD.run_corpus( + d, {"sqlitedb": _MOD.RunSpec(needs_db_fixture=True)}, tmp_path + ) + assert len(failures) == 1 + assert "sqlitedb.sqlite" in failures[0] + assert not fixture.exists(), ( + "the gate created the fixture it was checking for — sqlite3 " + "materialises an absent database, so the URL must not be " + "handed over at all" + ) + + class TestHermeticEnvironment: """The gate must measure the examples, not the developer's shell. @@ -530,6 +602,36 @@ def test_absent_sentinel_fails_the_gate_at_exit_zero( d, {"degraded": _MOD.RunSpec(expect="graceful arm")}, tmp_path ) == [] + def test_a_hanging_example_is_reported_as_a_timeout( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The budget exists so a hung program fails the hook instead of + blocking it, and until now nothing reached that branch. + + A purpose-built sleeper rather than a real example: `life.vera` is + the only long-running one in the corpus and it is skipped, so + driving the branch through it would mean un-skipping it. The + budget is monkeypatched down instead of the sleep being made long, + so the test costs a second rather than the real budget. + """ + src = """\ +public fn main(-> @Unit) + requires(true) + ensures(true) + effects() +{ + IO.sleep(30000) +} +""" + d = _corpus(tmp_path, {"sleeper": src}) + monkeypatch.setattr(_MOD, "TIMEOUT_SECONDS", 5) + failures = _MOD.run_corpus(d, {"sleeper": _MOD.RunSpec()}, tmp_path) + assert len(failures) == 1 + # The timeout wording specifically, not merely *a* failure: a + # sleeper that died some other way would also produce one. + assert "5s budget" in failures[0] + assert "hung rather than terminating" in failures[0] + def test_missing_file_for_a_spec_is_a_failure( self, tmp_path: Path ) -> None: @@ -551,6 +653,53 @@ def test_missing_file_for_a_spec_is_a_failure( assert "covers nothing" in failures[0] +# --------------------------------------------------------------------------- +# The report +# --------------------------------------------------------------------------- + + +class TestErrorBlocks: + """Each error kind gets its own header carrying its own count. + + Filing one kind's lines under another's header misreports both — a + reader who counts the lines beneath `COVERAGE ERRORS (n)` gets a + number the header disagrees with. + """ + + def test_doc_errors_are_never_filed_under_the_coverage_count( + self, + ) -> None: + """Two error kinds, two labelled blocks, each counting its own. + Printing documentation mismatches beneath a `COVERAGE ERRORS (n)` + header whose n excludes them misreports both — the reader counts + the lines and gets a different number from the one on the header. + """ + blocks = _MOD.error_blocks( + coverage_errors=["one coverage problem"], + doc_errors=["one doc problem", "another doc problem"], + failures=[], + ) + text = "\n".join(blocks) + assert "COVERAGE ERRORS (1)" in text + assert "DOCUMENTATION MISMATCH (2)" in text + assert "one doc problem" in text + # Every reported line sits under a header, and every header's count + # matches the lines beneath it. + counted = { + int(m) for m in re.findall(r"\((\d+)\)", text) + } + assert counted == {1, 2} + + def test_error_blocks_are_empty_when_nothing_is_wrong(self) -> None: + assert _MOD.error_blocks([], [], []) == [] + + def test_runtime_failures_get_their_own_counted_block(self) -> None: + blocks = _MOD.error_blocks([], [], ["boom: exited 1"]) + text = "\n".join(blocks) + assert "RUNTIME FAILURES (1)" in text + assert "boom: exited 1" in text + + # --------------------------------------------------------------------------- # The TESTING.md cross-check # --------------------------------------------------------------------------- @@ -652,38 +801,47 @@ def test_heading_present_but_table_empty_is_an_error(self) -> None: assert len(errors) == 1 assert "no rows" in errors[0].lower() - def test_doc_errors_are_never_filed_under_the_coverage_count( - self, - ) -> None: - """Two error kinds, two labelled blocks, each counting its own. - Printing documentation mismatches beneath a `COVERAGE ERRORS (n)` - header whose n excludes them misreports both — the reader counts - the lines and gets a different number from the one on the header. + def test_a_fenced_hash_line_does_not_end_the_subsection(self) -> None: + """`#` at column 0 inside a fence is a shell comment, not a + heading. TESTING.md carries 32 such lines today, none of them + between this heading and its table — so the guard is not fixing + a present breakage but removing a trap: adding an ordinary + annotated code block above the table would otherwise empty it + and fail the gate on a well-formed document. """ - blocks = _MOD.error_blocks( - coverage_errors=["one coverage problem"], - doc_errors=["one doc problem", "another doc problem"], - failures=[], + doc = ( + "### Example execution coverage\n\n" + "```bash\n" + "# regenerate the table\n" + "python scripts/check_examples_run.py\n" + "```\n\n" + "| Example | Executed by | Harness gate |\n" + "|---------|-------------|--------------|\n" + "| `a.vera` | prose | runs |\n\n" + "## Next section\n" ) - text = "\n".join(blocks) - assert "COVERAGE ERRORS (1)" in text - assert "DOCUMENTATION MISMATCH (2)" in text - assert "one doc problem" in text - # Every reported line sits under a header, and every header's count - # matches the lines beneath it. - counted = { - int(m) for m in re.findall(r"\((\d+)\)", text) - } - assert counted == {1, 2} + assert _MOD.parse_testing_table(doc) == {"a": "runs"} + assert _MOD.check_testing_md(doc, {"a": _MOD.RunSpec()}, {}) == [] - def test_error_blocks_are_empty_when_nothing_is_wrong(self) -> None: - assert _MOD.error_blocks([], [], []) == [] + def test_an_unfenced_hash_line_still_ends_the_subsection(self) -> None: + """The guard must not swallow real headings — the complement, + without which fence-awareness could degenerate into never + terminating.""" + doc = ( + "### Example execution coverage\n\n" + "| `a.vera` | prose | runs |\n\n" + "## Next section\n\n" + "| `ghost.vera` | prose | runs |\n" + ) + assert _MOD.parse_testing_table(doc) == {"a": "runs"} - def test_runtime_failures_get_their_own_counted_block(self) -> None: - blocks = _MOD.error_blocks([], [], ["boom: exited 1"]) - text = "\n".join(blocks) - assert "RUNTIME FAILURES (1)" in text - assert "boom: exited 1" in text + def test_the_shipped_document_parses_to_every_example(self) -> None: + """End to end on the real file: the parse finds one row per + example, so neither guard has quietly changed what it reads.""" + text = (_ROOT / "TESTING.md").read_text(encoding="utf-8") + rows = _MOD.parse_testing_table(text) + assert rows is not None + assert set(rows) == set(_MOD.example_names(_ROOT / "examples")) def test_table_parse_stops_at_the_next_heading(self) -> None: """A row-shaped line in a later section must not be swept in as an From e8925607a7af6fe4c9added2b8b77d7bea8a4b51 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 15:08:53 +0100 Subject: [PATCH 36/69] Assert the error blocks by position, not merely by presence The error-blocks test checked that both headers and every error string appeared somewhere in the report, and that the two counts were right. All three hold for a report that emits both headers first and then every error line -- each string present, each count correct, and every error attributed to a header that did not count it. That is precisely the misreporting the block structure exists to prevent, so presence was testing everything about the report except the thing it promises. The cell now takes the index of each header and each error and asserts the containment directly: the coverage error falls between the coverage header and the documentation one, and both documentation errors fall after the documentation header. A `_sole_index` helper does the lookup and fails on a duplicate or absent line, so a match count of anything but one is an error rather than a silently-chosen first hit. The runtime-failures cell gets the same treatment, since it had the same weakness one block down. Proven in both directions with a scratch mutant that hoists every header above every error line: it SURVIVES the presence-only assertions as they stood at HEAD, and is caught by the positional ones. The mutant joins the standing battery, which is now 40 for 40. Skip-changelog: test assertion strengthening; no behaviour change (the TESTING.md and docs/ edits are the doc-counts oracle's line-count row and its regenerated site assets) Co-Authored-By: Claude --- FAQ.md | 2 +- README.md | 2 +- ROADMAP.md | 2 +- TESTING.md | 4 +-- docs/llms-full.txt | 2 +- tests/test_check_examples_run.py | 54 +++++++++++++++++++++++++------- vera/README.md | 2 +- 7 files changed, 50 insertions(+), 18 deletions(-) diff --git a/FAQ.md b/FAQ.md index cdb8a797..77150dd1 100644 --- a/FAQ.md +++ b/FAQ.md @@ -279,7 +279,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 11,075 tests, including a 229-program conformance suite +- 11,134 tests, including a 229-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/README.md b/README.md index ada61e88..ed41e3f9 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ cp /path/to/vera/SKILL.md ~/.claude/skills/vera-language/SKILL.md ## Project status -Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 11,075 tests, 95% Python code coverage, 229 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. +Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 11,134 tests, 95% Python code coverage, 229 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. The reference compiler — parser, AST, type checker, contract verifier (Z3), WASM code generator, module system, browser runtime, and runtime contract insertion — is working. The language specification is in draft across [14 chapters](spec/). diff --git a/ROADMAP.md b/ROADMAP.md index c3c90e42..754c8cab 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,7 +12,7 @@ Ordering derives from the design principles ([DESIGN.md](DESIGN.md)): verificati ## Where we are -11,075 tests, 229 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. +11,134 tests, 229 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. ## Stage 19 — The verification completeness sprint diff --git a/TESTING.md b/TESTING.md index 42cb5ce6..120ed961 100644 --- a/TESTING.md +++ b/TESTING.md @@ -6,7 +6,7 @@ This is the single source of truth for Vera's testing infrastructure, coverage d | Metric | Value | |--------|-------| -| **Tests** | 11,075 across 168 files (~155,000 lines of test code; 10,896 passed + 26 stress, 153 skipped) | +| **Tests** | 11,134 across 169 files (~155,000 lines of test code; 10,955 passed + 26 stress, 153 skipped) | | **Compiler code coverage** | 95% Python, 87% JavaScript (CI minimum: 80%) | | **Conformance programs** | 229 programs across 9 spec chapters, validating every language feature | | **Example programs** | 42, all validated through `vera check` + `vera verify` | @@ -215,7 +215,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_release.py` | 47 | 566 | Release policy and registry verification (#481): strict project-name and version parsing/comparison, version-bump/TestPyPI/recovery planning, exact confirmation and immutable-tag guards, first-parent version-introduction discovery, package-change recovery refusal, non-empty CHANGELOG extraction, one-wheel/one-sdist SHA-256 manifests, malformed registry-response handling, missing/filename/hash propagation retries, exact filename/hash verification, and CLI dispatch/GitHub-output wiring. An autouse fixture scrubs hook-exported `GIT_*` variables so the tmp-repo git calls (fixture helpers and `release.py`'s own) never resolve to the developer's repository when the suite runs inside a pre-commit hook. | | `test_check_doc_counts.py` | 54 | 665 | `check_doc_counts.py`'s pure per-document checks: KNOWN_ISSUES refactoring line counts (±10% tolerance band incl. the exact-boundary case, drift detection, empty-file citation, hyphenated paths, missing file/section/rows, the #419 empty-section sentinel + its cannot-mask-a-malformed-table dual), HISTORY version-row format (issue-link limit, ` — ` separator rejection, dateless-row and prose exemption, line-number reporting), the TESTING.md tests breakdown (parts summing to the collected total, a self-consistent-but-stale row, and the reworded-row error), and vera/README.md's Test Suite counts (all four checked independently — mutation-validated by dropping each citation in turn — plus the reworded-paragraph error, a thousands-separator case pinning that every one of the four counts is read comma-tolerantly, and the two section-anchoring cases — a reworded paragraph with decoy counts in a later section, and a renamed heading, both of which must fail loud rather than match across the section boundary). The reworded case is a test in its own right for both new checks: a pattern that matches nothing must be an error, or rewording the sentence silently switches the gate off. Also the release count (README's status line and HISTORY's total against each other and against `git tag`: the matching case, the one-ahead release cut that `release.yml` has not tagged yet, that +1 being the ONLY slack once the version is tagged, the two-behind drift that actually shipped, per-document reporting, and a tagless checkout standing the oracle down without standing down the cross-check), plus the tag reader itself against real repositories built in `tmp_path` — release tags read, `nightly`/`-rc1` not counted as releases, and both no-evidence answers (`None` rather than `[]`, since an empty list would read as zero releases and make every documented count wrong) for a tagless checkout and a directory that is not a repository at all. CONTRIBUTING.md's pre-commit hook count is checked the same way, reworded-sentence case included; and the CI-pipeline lint row against `ci.yml`'s lint job — a matching row, a step present in CI but absent from the row (the drift that shipped), a row entry CI no longer runs, the same set in a different order, a reworded row and a renamed job (both errors, not skips), that only the lint job is read rather than the whole workflow, and the shipped pair both clean and red with one entry dropped | | `test_grammar_alignment.py` | 44 | 309 | `check_grammar_alignment.py` gate ([#683](https://github.com/aallan/vera/issues/683)): both extractors (Lark headers with the `?`/`!`/`_` markers stripped, template parameters and rule priorities tolerated, and `-> alias` names deliberately not collected; spec headers from ```ebnf fences only), the allowlist arithmetic in all three directions — unwaived drift, a spent entry both files now have, and an entry whose premise broke — one case per waiver proving the fact it rests on is actually checked, the name-deleted-from-both-files case that must not read as agreement, a mutation restoring the spec's old `assert_stmt` name, non-vacuous extraction, and the false positive the issue itself rested on: `qualified_call` and `module_call` are spec headers Lark expresses as aliases, and must never be reported as drift. Three pin the premise checks against ways they used to pass vacuously: an alias surviving only inside a `//` comment must not hold its waiver up, an alias that moved to another rule must fail the waiver naming `fn_call`, and a spent waiver whose premise also broke must yield one instruction rather than two opposite ones | -| `test_check_examples_run.py` | 59 | 853 | `check_examples_run.py`, the harness gate that RUNS the examples. Five separable parts, each in both directions. **The coverage rule** — the shipped tables cover the shipped corpus exactly, and an unclassified example, a stale `RUN_SPECS` or `SKIPS` key whose file is gone, a name in both tables, and a skip citing an undocumented property are each an error; the empty corpus is an error too, since a glob that stops matching would otherwise report success over zero programs. Plus the specs' own well-formedness: every named entry point is `public` in its example, every no-main example pins one (or `vera run` would fall back to an arbitrary first export), and no skip property is unused. **The runner** — a seeded `tmp_path` corpus proves it goes red on a program that type-checks and compiles but traps at run time, green on one that does not, and reports only the broken member of a mixed pair; a fixture whose first export is clean and whose named one traps proves `spec.fn` is actually honoured rather than ignored; a writer program proves each run gets a scratch working directory, so a gate run leaves nothing beside the examples. **The TESTING.md cross-check** — missing row, extra row, rename (reported naming both sides), wrong disposition and wrong skip property are errors, the parse stops at the next heading so a row-shaped line in a later section is not swept in, and both a reworded heading and a heading whose table has vanished fail loud rather than finding nothing to compare. **The output signal** -- the second half of the two-signal discipline `check_examples.py` established: the fallback note that `vera run` prints when it cannot use the named entry point is a failure even at exit 0, an absent `expect` sentinel is a failure even at exit 0, and a spec without one asserts nothing about output; end to end, a privatised `main` and a program that completed down a graceful arm each go red, and the same program passes once its own output is the sentinel, so the check reads the output rather than always failing. The three environment-dependent specs are required to carry a sentinel, and the runner's use of BOTH streams is pinned structurally -- `vera run` writes the note to stderr and nothing there on a clean exit, so no fixture can distinguish reading both streams from reading stdout alone, and a tripwire wired to the wrong stream is no tripwire. Also the hermetic-environment property: an ambient `VERA_DB_URL` or provider key is stripped so a gate run cannot be pointed at a real database or turned into a billed API call, a fixture spec puts its own URL back, and every neutralised name is checked to be one `vera/runtime/` actually reads | +| `test_check_examples_run.py` | 59 | 885 | `check_examples_run.py`, the harness gate that RUNS the examples. Five separable parts, each in both directions. **The coverage rule** — the shipped tables cover the shipped corpus exactly, and an unclassified example, a stale `RUN_SPECS` or `SKIPS` key whose file is gone, a name in both tables, and a skip citing an undocumented property are each an error; the empty corpus is an error too, since a glob that stops matching would otherwise report success over zero programs. Plus the specs' own well-formedness: every named entry point is `public` in its example, every no-main example pins one (or `vera run` would fall back to an arbitrary first export), and no skip property is unused. **The runner** — a seeded `tmp_path` corpus proves it goes red on a program that type-checks and compiles but traps at run time, green on one that does not, and reports only the broken member of a mixed pair; a fixture whose first export is clean and whose named one traps proves `spec.fn` is actually honoured rather than ignored; a writer program proves each run gets a scratch working directory, so a gate run leaves nothing beside the examples. **The TESTING.md cross-check** — missing row, extra row, rename (reported naming both sides), wrong disposition and wrong skip property are errors, the parse stops at the next heading so a row-shaped line in a later section is not swept in, and both a reworded heading and a heading whose table has vanished fail loud rather than finding nothing to compare. **The output signal** -- the second half of the two-signal discipline `check_examples.py` established: the fallback note that `vera run` prints when it cannot use the named entry point is a failure even at exit 0, an absent `expect` sentinel is a failure even at exit 0, and a spec without one asserts nothing about output; end to end, a privatised `main` and a program that completed down a graceful arm each go red, and the same program passes once its own output is the sentinel, so the check reads the output rather than always failing. The three environment-dependent specs are required to carry a sentinel, and the runner's use of BOTH streams is pinned structurally -- `vera run` writes the note to stderr and nothing there on a clean exit, so no fixture can distinguish reading both streams from reading stdout alone, and a tripwire wired to the wrong stream is no tripwire. Also the hermetic-environment property: an ambient `VERA_DB_URL` or provider key is stripped so a gate run cannot be pointed at a real database or turned into a billed API call, a fixture spec puts its own URL back, and every neutralised name is checked to be one `vera/runtime/` actually reads | | `test_check_editor_grammars.py` | 20 | 249 | `check_editor_grammars.py` gate ([#1156](https://github.com/aallan/vera/issues/1156)): the registry read (every effect in, every ability out, and the four names the grammars actually drifted on present so the set checked is non-vacuous), the word-boundary presence test across all three grammar formats (JSON, plist XML, Vim keyword list) including the prefix pair `Http`/`HttpServer` in both directions, the deliberate comment-mention false pass, a metacharacter pair that only passes when the name is matched literally (`A.B` present, `A0B` not), and the empty registry; the gate's primary path end to end — a listed grammar with an effect stripped out, and a listed README with one stripped out of its prose bullet, each red against an otherwise-clean mirrored tree; the completeness guard — a grammar discovered under `editors/` but absent from `GRAMMARS` fails that same tree, over three discovery routes (`.el` outside a syntax directory, a tree-sitter `.scm` query set, a `.tmLanguage.json` filed anywhere but `syntaxes/`); and the registry's provenance, run as a subprocess against a throwaway checkout whose `vera` package names an effect the grammars do not, which is the only way to see that the list comes from the tree being checked rather than from site-packages. The shipped grammars and READMEs are currently clean | | `test_check_explicit_encoding.py` | 54 | 254 | `check_explicit_encoding.py` gate (#645): flags text-mode `open()` / `read_text()` / `write_text()` **and** `subprocess.run/Popen/check_output(..., text=True)` captures missing an `encoding="utf-8"` literal (rejects non-literal / non-UTF-8 values), skips binary/bytes-mode calls, honours the `# encoding-exempt` opt-out, and asserts the shipped repo is clean | | `test_check_limitations_sync.py` | 6 | 108 | `check_limitations_sync.py` section extraction: table-rows-only issue harvesting, prose-link exemption, bounding at the next second-level heading, `None` for absent or sub-level headings so renamed sections fail loudly; plus the #852 fail-loud rule: an UNKNOWN issue state under `--check-states` (gh missing / auth failure / timeout) is an error, never a silent pass | diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 259ceeee..4c3df19a 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -3258,7 +3258,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 11,075 tests, including a 229-program conformance suite +- 11,134 tests, including a 229-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/tests/test_check_examples_run.py b/tests/test_check_examples_run.py index a86f98d5..ff75da35 100644 --- a/tests/test_check_examples_run.py +++ b/tests/test_check_examples_run.py @@ -658,8 +658,25 @@ def test_missing_file_for_a_spec_is_a_failure( # --------------------------------------------------------------------------- +def _sole_index(blocks: list[str], needle: str) -> int: + """The index of the one line containing *needle*. + + Position, not presence: `in "\\n".join(blocks)` answers "is this text + somewhere in the report", which stays true however the lines are + ordered. What the report promises is that each error sits *beneath + its own header*, and only an index can say that. + """ + matches = [i for i, line in enumerate(blocks) if needle in line] + assert len(matches) == 1, ( + f"expected exactly one line containing {needle!r}, got " + f"{len(matches)}: {blocks!r}" + ) + return matches[0] + + class TestErrorBlocks: - """Each error kind gets its own header carrying its own count. + """Each error kind gets its own header carrying its own count, with + its own errors underneath it. Filing one kind's lines under another's header misreports both — a reader who counts the lines beneath `COVERAGE ERRORS (n)` gets a @@ -673,20 +690,34 @@ def test_doc_errors_are_never_filed_under_the_coverage_count( Printing documentation mismatches beneath a `COVERAGE ERRORS (n)` header whose n excludes them misreports both — the reader counts the lines and gets a different number from the one on the header. + + Asserted positionally. Presence plus counts is satisfied by a + report that emits both headers first and then every error line — + each string is there, each count is right, and every error is + attributed to the wrong header. That is the shape this test + exists to reject, so the ordering is what it checks. """ blocks = _MOD.error_blocks( coverage_errors=["one coverage problem"], doc_errors=["one doc problem", "another doc problem"], failures=[], ) - text = "\n".join(blocks) - assert "COVERAGE ERRORS (1)" in text - assert "DOCUMENTATION MISMATCH (2)" in text - assert "one doc problem" in text - # Every reported line sits under a header, and every header's count - # matches the lines beneath it. + cov_header = _sole_index(blocks, "COVERAGE ERRORS (1)") + cov_error = _sole_index(blocks, "one coverage problem") + doc_header = _sole_index(blocks, "DOCUMENTATION MISMATCH (2)") + doc_first = _sole_index(blocks, "one doc problem") + doc_second = _sole_index(blocks, "another doc problem") + + # The coverage error sits under the coverage header, and both doc + # errors under the documentation one — so no error can be read as + # belonging to a header that did not count it. + assert cov_header < cov_error < doc_header + assert doc_header < doc_first + assert doc_header < doc_second + + # And every header's count still matches the lines beneath it. counted = { - int(m) for m in re.findall(r"\((\d+)\)", text) + int(m) for m in re.findall(r"\((\d+)\)", "\n".join(blocks)) } assert counted == {1, 2} @@ -695,9 +726,10 @@ def test_error_blocks_are_empty_when_nothing_is_wrong(self) -> None: def test_runtime_failures_get_their_own_counted_block(self) -> None: blocks = _MOD.error_blocks([], [], ["boom: exited 1"]) - text = "\n".join(blocks) - assert "RUNTIME FAILURES (1)" in text - assert "boom: exited 1" in text + assert ( + _sole_index(blocks, "RUNTIME FAILURES (1)") + < _sole_index(blocks, "boom: exited 1") + ) # --------------------------------------------------------------------------- diff --git a/vera/README.md b/vera/README.md index 8728c026..bd45353a 100644 --- a/vera/README.md +++ b/vera/README.md @@ -752,7 +752,7 @@ The `ERROR_CODES` dict in `errors.py` maps every code to a short description (15 ## Test Suite -Testing spans a **pytest suite** of 11,075 tests across 168 files — compiler-internals unit tests plus a **conformance suite** (229 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. +Testing spans a **pytest suite** of 11,134 tests across 169 files — compiler-internals unit tests plus a **conformance suite** (229 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. See **[TESTING.md](../TESTING.md)** for the comprehensive testing reference -- test file table, conformance suite details, compiler code coverage, language feature coverage, helper conventions, validation scripts, CI pipeline, and guidelines for adding tests. From d0621657c4525cc726b9a87f8753473e710c9772 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 12:34:45 +0100 Subject: [PATCH 37/69] Refuse a bare name two imports supply, in every namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec 8.5 ordered a local declaration against an import and gave the module-qualified form for reaching what a clash hides, but defined no order between two imports that both supply one name. Neither did the implementation: a module importing two dependencies that each export `forall fn gen` — one returning `@Int`, one `@Bool` — bound its bare call to whichever supplier a set of module paths yielded first, so one unchanged file was check-green on one run and `[E121] body has type Bool` on the next. Measured at the branch point: accepted on hash seeds 0, 2 and 3, rejected on 1, 4, 5, 6 and 7, with the winner tracking module-name hash order rather than which import is written first. Spec 8.5.2.2 now states the rule — a program MUST NOT leave a namespace with two imports supplying one bare function name — and the checker enforces it as `E155`, a check-phase code for what is a scope question. Refusing is what removes the flap rather than labelling it: with no pick to make there is no iteration order left to expose, which a merely deterministic first-wins order would not have achieved. The refusal is definition-gated, matching the `E608` rail it generalises, so an unused clash is still refused and rewriting a call in module-qualified form does not lift it; a local declaration or a selective import does, and both are exercised through to their runtime value. Both layers read one derivation. `namespace_fn_names` now exposes its ambiguity per namespace as well as unioned — the checker asks for its own namespace's clashes, to report at its own import and to keep the name out of the type environment, and codegen's rail asks the union, which is the question it has always asked. An ambiguous name is not injected at all: reporting the clash while binding one supplier would leave the follow-on diagnostics keyed to whichever module the injection loop reached first, which is the nondeterminism the refusal exists to remove. Spec 8.5.4 gives constructor names the same shadowing rules as function names, so the data namespaces are folded in rather than left to make that sentence false. They flapped the same way — two modules each exporting a `public data Shape` with different constructor field types type-checked on hash seeds 2, 8, 9, 10 and 11 and reported `[E213]` on 0, 1, 3, 4, 5, 6 and 7 — and the accepting seeds were the worse half: `check` and `verify` both passed, and the program died at `run` with an `E609` at line 0 of the entry file naming two modules the entry never imported. Data types are `E156` and constructors `E157`, one code per declaration namespace as codegen already splits E608/E609/E610, and reported independently because two modules exporting differently-named types can clash on a constructor alone. Their remedy differs from the function one and the diagnostics say so. E609/E610 refuse two modules' same-named data declarations by DECLARATION, consulting neither visibility nor the importer's filter nor local shadowing — the relaxation E608 received in #1281 has no data-side twin — so narrowing an import and declaring the type locally were both measured against the fixture and both still fail at compile. Marking one declaration `private` fails the same way. Those two diagnostics therefore prescribe renaming, and a cell pins the measurement so the text cannot drift into offering remedies that do not work; the rail over-breadth behind it is tracked as #1317, with a KNOWN_ISSUES row and a spec 8.11 limitation entry. A name the built-in registry already owns is not a clash: the injection loops are `setdefault` over a `TypeEnv` the built-ins populate first, so a dependency exporting its own `option_map` never wins the bare name — measured as `E201` against the prelude's two-argument signature. The first cut of this refusal did not pass that snapshot and reported two such dependencies as a clash, which was a new rejection where the branch point was green. `namespace_fn_names`' claim that its ambiguity half is identical with or without the prelude argument was wrong for the same reason; it is corrected to the measured truth, and the codegen call ordering it rests on is pinned. The two #1281 cells the checker now catches first drive the shape through `build_multi_module_past_check` and assert both layers, so the rail behind the refusal is still exercised. Five conformance programs cover the two import orders and the disambiguated form, four more for the data-side pair; the new test file pins the determinism in fresh subprocesses across four hash seeds, which the base tree cannot satisfy. The corpus moves nothing — all 268 pre-existing programs answer identically on both trees. Closes #1304 Co-Authored-By: Claude --- AGENTS.md | 4 +- CHANGELOG.md | 4 +- CLAUDE.md | 2 +- KNOWN_ISSUES.md | 1 + ROADMAP.md | 2 +- SKILL.md | 8 +- TESTING.md | 27 +- docs/SKILL.md | 8 +- docs/index.html | 2 +- docs/index.md | 2 +- docs/llms-full.txt | 15 +- docs/llms.txt | 2 +- spec/08-modules.md | 80 +- spec/11-compilation.md | 2 +- .../ch08_ambiguous_import_adt_lib_bool.vera | 10 + .../ch08_ambiguous_import_adt_lib_int.vera | 10 + .../ch08_ambiguous_import_adt_rejected.vera | 18 + ...ambiguous_import_adt_swapped_rejected.vera | 17 + .../ch08_ambiguous_import_lib_bool.vera | 13 + .../ch08_ambiguous_import_lib_int.vera | 13 + .../ch08_ambiguous_import_qualified.vera | 22 + .../ch08_ambiguous_import_rejected.vera | 15 + ...h08_ambiguous_import_swapped_rejected.vera | 14 + tests/conformance/manifest.json | 130 +- tests/module_fixture_helpers.py | 104 +- tests/test_ambiguous_import_refusal_1304.py | 1109 +++++++++++++++++ tests/test_module_generic_collision_1281.py | 65 +- vera/README.md | 4 +- vera/_since.py | 3 + vera/checker/core.py | 12 + vera/checker/modules.py | 193 ++- vera/codegen/core.py | 15 +- vera/codegen/modules.py | 40 +- vera/errors.py | 3 + vera/monomorphize.py | 255 +++- 35 files changed, 2095 insertions(+), 129 deletions(-) create mode 100644 tests/conformance/ch08_ambiguous_import_adt_lib_bool.vera create mode 100644 tests/conformance/ch08_ambiguous_import_adt_lib_int.vera create mode 100644 tests/conformance/ch08_ambiguous_import_adt_rejected.vera create mode 100644 tests/conformance/ch08_ambiguous_import_adt_swapped_rejected.vera create mode 100644 tests/conformance/ch08_ambiguous_import_lib_bool.vera create mode 100644 tests/conformance/ch08_ambiguous_import_lib_int.vera create mode 100644 tests/conformance/ch08_ambiguous_import_qualified.vera create mode 100644 tests/conformance/ch08_ambiguous_import_rejected.vera create mode 100644 tests/conformance/ch08_ambiguous_import_swapped_rejected.vera create mode 100644 tests/test_ambiguous_import_refusal_1304.py diff --git a/AGENTS.md b/AGENTS.md index 37daba5c..5fc4929c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Read `SKILL.md` for the full language reference. It covers syntax, slot referenc ### Conformance programs as reference -The conformance suite in `tests/conformance/` contains 229 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-three negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) instead must *fail* with the E-code in their `expected_error` field, at the stage their `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. +The conformance suite in `tests/conformance/` contains 229 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-seven negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) instead must *fail* with the E-code in their `expected_error` field, at the stage their `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. ### Workflow @@ -198,7 +198,7 @@ When implementing a new language feature, write the conformance program *first* ### Invariants -- All 229 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first +- All 229 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first - All 42 examples in `examples/` must pass `vera check` and `vera verify` - `mypy vera/` must be clean - `pytest tests/ -v` must pass diff --git a/CHANGELOG.md b/CHANGELOG.md index 41f0d782..7eea9565 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **Two imports supplying one bare name are refused, in every namespace** ([#1304](https://github.com/aallan/vera/issues/1304)). Spec §8.5 ordered a local declaration against an import (§8.5.2) and gave the module-qualified form for reaching what a clash hides (§8.5.3), but defined no order between two *imports* that both supply one name. Neither did the implementation, and the gap was reachable: a module importing two dependencies that each export `forall fn gen` — one returning `@Int`, one `@Bool` — bound its bare call to whichever supplier a set of module paths happened to yield first, so one unchanged file was `vera check`-green on one run and `[E121] body has type Bool` on the next. Measured at the branch point across eight consecutive runs and eight hash seeds: accepted on seeds 0, 2 and 3, rejected on 1, 4, 5, 6 and 7, with the winner tracking module-name hash order rather than which import is written first. Codegen's E608 rail caught the *entry-visible* pair before it could matter there; the flap lived in the shapes the rail only reached at compile, from inside a module the entry program merely imports. Spec §8.5.2.2 now states the rule — a program **MUST NOT** leave a namespace with two imports supplying one bare function name — and the checker enforces it as **E155**, a check-phase code for a scope question that had been enforced by a codegen rail at the wrong layer. Refusing is what removes the flap rather than merely labelling it: with no pick to make, there is no iteration order left to expose, which a deterministic first-wins order would not have achieved (it would make the resolved declaration implicit in import sequence, §0.2.2, and let a library *adding* an export silently rebind a downstream bare call). The refusal is **definition-gated**, matching the rail it generalises: it fires because the import pair exists, not because a body names it, so an entry program importing two suppliers and never calling either is refused exactly as E608 already refused it, and rewriting a bare call in module-qualified form does not lift it. Two shapes clear it, both exercised through to their runtime value: a **local declaration** of the name (§8.5.2 — every bare call is then the local one, and each import stays reachable through `dep::name(...)`), or a **selective import** narrowing the other module's list. The ambiguity predicate is the one `namespace_fn_names` already derived for #1281 and #1299, now exposed per namespace as well as unioned, so the layer that refuses early and the layer that backstops it cannot disagree about which shape is ambiguous; the E608 condition keeps its cell, driven through a door that bypasses the checker. An ambiguous name is not injected into the type environment at all — reporting the clash while binding one supplier would leave the follow-on diagnostics keyed to whichever module the injection loop reached first, which is the nondeterminism the refusal exists to remove — so a bare call to it misses with the ordinary `W`-severity `E200` instead. **The data namespaces flapped the same way and are folded in.** Spec §8.5.4 gives constructor names the same shadowing rules as function names, which a function-only refusal would have made false: two modules each exporting a `public data Shape` with different constructor field types type-checked on some hash seeds and reported `[E213]` on others (accepted on seeds 2, 8, 9, 10 and 11; rejected on 0, 1, 3, 4, 5, 6 and 7), and the accepting seeds were the worse half — `check` **and** `verify` both passed, and the program died at `run` with an `E609` located at line 0 of the entry file, naming two modules the entry never imported. Data types are now **E156** and constructors **E157**, one code per declaration namespace exactly as codegen splits E608/E609/E610, and reported independently because they come apart: two modules exporting differently-named types that share a constructor name clash on the constructor alone. Their remedy differs from the function one and says so — E609/E610 refuse two modules' same-named data declarations by DECLARATION, consulting neither visibility nor the importer's filter nor local shadowing (the relaxation E608 received in [#1281](https://github.com/aallan/vera/issues/1281) has no data-side twin), so narrowing an import or shadowing the name locally leaves the program `E609` at compile. Both were measured against the fixture and both fail — as does marking one declaration `private` — so the two diagnostics prescribe renaming, and a cell pins that measurement so the fix text cannot drift into offering remedies that do not work. That rail over-breadth is now tracked as [#1317](https://github.com/aallan/vera/issues/1317). **A name the built-in registry already owns is not a clash** — the injection loops are `setdefault` over a `TypeEnv` the built-ins populate first, so a dependency exporting its own `option_map` never wins the bare name (measured as `E201` against the *prelude's* two-argument signature). The first cut of this refusal did not pass the built-in snapshot and reported two such dependencies as a clash, which was a new rejection rather than an earlier one; `namespace_fn_names`' claim that its ambiguity half is identical with or without the prelude argument was wrong for the same reason and is corrected, with the codegen call ordering it depends on now pinned by a cell. + - **A user-defined `fn get` / `fn put` is no longer hijacked by an enclosing handler** ([#1284](https://github.com/aallan/vera/issues/1284)). Three sites answered "does this `get` mean the user's declaration or the effect operation?" independently. The checker answers user-fn-first — `_check_call_with_args` looks a bare name up as a function before it looks it up as an operation, so a declaration named `get` owns every bare `get(...)` in its scope, which an arity or argument-type error at such a call site proves by reporting the *user's* signature (E201/E202). Codegen answered twice more: the declared-effect row in `vera/codegen/functions.py` withheld the intrinsic when `_fn_sigs` already owned the name, and the handler expression in `vera/wasm/calls_handlers.py` installed `get`/`put` unconditionally. From `vera check`-green source that produced, depending on the nesting shape, a **silently wrong value** (`nat_to_int(get(3))` under `handle[State](@Int = 5)` returned the cell's 5 for the function's 4, and the argument was not even emitted), a **module WASM validation rejects** (a `@Bool`-returning user `get` took `state_get_Int`'s i64 into an `i32` position; different-family nesting took the *enclosing* cell's getter at the wrong width), or a **spurious `[E602]`** in which the #1233 unaddressable-cell gate refused `main` outright, naming "a bare or qualified State operation `get`" the program never contained. The repair is one predicate, `vera.slots.bare_call_denotes_user_fn`, stating the checker's rule once and consumed by the bare-call dispatch in `vera/wasm/calls.py` (which now gates the clause-inline registry, the host-cell intrinsics and the addressability gate together), by the three bare-`FnCall` result-type inference sites, and by the monomorphizer's discovery walk — each passing its own name table, so the sites cannot answer differently about the table they share. Gating the *dispatch* rather than the *registries* is what makes it correct rather than merely consistent: the registries record which cell an op name reaches, which is true whatever the program's declarations are called, and withholding an entry answered both questions with one table. That is why the gate-only fix measured during PR #1283's review turned the loud skip into a differently-broken module, and why it also cost the qualified spelling its cell — `State.put(5)` in a function that also declares `fn put` compiled to `call $vera.put` and failed to link, which now lowers to the intrinsic the checker always meant. Discovery's `MonoContext.fn_names` moves to the same lookup-time question, so a `get(())` fixing a generic's type variable under a handler names the clone the rewrite emits. All 256 conformance and example programs emit byte-identical WAT. **The `W002` async-commutativity warning was the same defect in the checker's own file** and is corrected with them: `_collect_expr_effects` asked `lookup_effect_op` before the scoped function lookup — the last op-first consumer — so a user function named after an operation contributed the *operation's* parent effect to the commutativity analysis instead of its own declared row, wrong in both directions. A **pure** `fn get`, in a program containing no `State` at all, drew `async argument performs State effects`; a `fn get` that performs `IO`, under a row naming `Http` first, drew **no warning**, because the walk bound the name to `Http.get`, which is inside the commutative whitelist, and silently withheld the eager-evaluation warning the program is owed. Both are pinned with rename controls — the byte-identical program with the helper called `gett` / `fetch` was correct throughout — beside a control that an unshadowed bare `get(())` under a `State` row still warns, so the fix cannot degenerate into never reporting `State`. The walk's comment claiming it resolves "like the call checker above" is now true. One caveat the predicate did not close on its own: codegen's name table was not scope-accurate, so a name the call site cannot see still answered "user-owned" there — a property of the table rather than of the rule, closed by [#1299](https://github.com/aallan/vera/issues/1299) below. - **A bare call is lowered against the names its call site can see** ([#1299](https://github.com/aallan/vera/issues/1299)). The [#1284](https://github.com/aallan/vera/issues/1284) ownership predicate is one rule read over two tables, and only one of them was a scope. The checker's is a lexical walk; codegen passed `set(_fn_sigs.keys())` — a flat mirror of every symbol the whole compilation absorbed — so a bare `get(())` the checker had resolved to a `State` operation was lowered as a call to a declaration the body cannot name. Four source shapes reach it, all `vera check`-green and all one defect: an imported module's **private** `fn get` (invisible, but still compiled in because the module's own bodies call it), a **public** one a selective import excludes, a `where` helper of a **`forall` parent** (which keeps a bare `_fn_sigs` key beside its clone-qualified one where a non-generic parent's helper does not), and the ability operation `show`, which `E151` does not reserve and which reaches the same table through the *intrinsic* gate rather than the operation one. How it lands is a property of the widths, not of the route: where the invisible declaration and the cell share a WAT type the module loads and returns the wrong value (7007 where the cell holds 42007), where they differ it fails to load (`type mismatch: expected i64, found i32`), and the generic-`where` route is always loud — the bare key exists in the signature table while no bare *symbol* is emitted, so the call dies at WAT assembly on `unknown func: failed to find name $get` with no E-code. The repair splits the two questions the one set was answering. `_known_fns` keeps the flat registry for `_translate_call`'s guard rail, which asks whether a *resolved* target — already mono-mangled, already `mod$…` rerouted — has an implementation, and is flat by nature. A new `_scoped_fns` carries the names visible in the compiling declaration's **lexical** scope, and that is what the ownership predicate reads: its namespace's own declarations plus the public, in-filter names of the imports *that namespace* makes (spec §8.6.4 — imports are never inherited, so a transitively-reached module contributes nothing to the entry program), the prelude, and the `where` helpers of every enclosing function. Module scope alone would not have closed the third route: a generic's helper *is* in the module and still is not in a sibling's scope. The narrowing is a strict subset of the registry by construction — every `$`-bearing key is admitted unconditionally, since `$` cannot occur in a Vera identifier and a mangled name is never what a bare source call spells — so it can only withdraw a name the flat table wrongly claimed. @@ -20,7 +22,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). All 258 pre-existing conformance and example programs emit byte-identical WAT, and all three gates now have conformance coverage: reverting any one of them turns the suite red. -- **E608 no longer refuses two modules' provably distinct generics** ([#1281](https://github.com/aallan/vera/issues/1281)). The flat-namespace collision rail exists because Pass 2.5 emits every imported function under one WASM name — but a generic emits nothing under its bare name, and since [#1274](https://github.com/aallan/vera/issues/1274) its clones live in a namespace chosen per *owner*: `gen$Bool` for a generic that owns the importer's bare name, `mod$$gen$Bool` for one that does not. A diamond where `base` declares a public `forall fn gen` and `mid1` a private one occupies two different namespaces and was refused outright with `Function 'gen' is defined in both imported module 'mid1' and 'base'`, while `vera verify` returned rc=0 on the same program — a loud verify-vs-compile disagreement. The rail now reads the same ownership classification the clone namespace does, and fires only when the pair really can collide: when either declaration is not a top-level generic (a non-generic *is* emitted under the bare `$name`), when both own the bare name, or when some namespace can name both — a module importing two dependencies that each export `gen` resolves its own bare call to one of them, and neither spec §8.5 nor the checker says which: the checker's pick is a set-iteration artefact, and the identical program accepts on one run and is rejected on the next (measured 4/4 over eight runs, stable under a fixed `PYTHONHASHSEED`, and unchanged by the textual order of the two imports), so that shape keeps its refusal rather than compiling a body chosen by coin-flip ([#1304](https://github.com/aallan/vera/issues/1304)). The registration moved with the message, as defence in depth: a qualified-only generic no longer injects a bare `_fn_sigs` or `_fn_ret_type_exprs` entry at all. Those two registries are read *per name* by consumers the clone classification says nothing about — `MonoContext.fn_names`, the [#1207](https://github.com/aallan/vera/issues/1207) shadow guard, and the WASM call-rewrite's return-type lookup — where first-module-wins would make the answer depend on registration order. What actually closes that shape is the #1299 scope narrowing above, which reaches the same consultors through the call site: reverting both withholdings leaves every suite and all 224 conformance programs green. They are kept, and pinned structurally on the tables they act on with one cell each, because nothing but those four consumers' current internals stops any of them from picking a winner. +- **E608 no longer refuses two modules' provably distinct generics** ([#1281](https://github.com/aallan/vera/issues/1281)). The flat-namespace collision rail exists because Pass 2.5 emits every imported function under one WASM name — but a generic emits nothing under its bare name, and since [#1274](https://github.com/aallan/vera/issues/1274) its clones live in a namespace chosen per *owner*: `gen$Bool` for a generic that owns the importer's bare name, `mod$$gen$Bool` for one that does not. A diamond where `base` declares a public `forall fn gen` and `mid1` a private one occupies two different namespaces and was refused outright with `Function 'gen' is defined in both imported module 'mid1' and 'base'`, while `vera verify` returned rc=0 on the same program — a loud verify-vs-compile disagreement. The rail now reads the same ownership classification the clone namespace does, and fires only when the pair really can collide: when either declaration is not a top-level generic (a non-generic *is* emitted under the bare `$name`), when both own the bare name, or when some namespace can name both — a module importing two dependencies that each export `gen` would resolve its own bare call to one of them, and spec §8.5 now refuses the name outright rather than ordering the two imports, so that shape keeps its refusal here as the backstop behind the check-phase refusal described below ([#1304](https://github.com/aallan/vera/issues/1304)). The registration moved with the message, as defence in depth: a qualified-only generic no longer injects a bare `_fn_sigs` or `_fn_ret_type_exprs` entry at all. Those two registries are read *per name* by consumers the clone classification says nothing about — `MonoContext.fn_names`, the [#1207](https://github.com/aallan/vera/issues/1207) shadow guard, and the WASM call-rewrite's return-type lookup — where first-module-wins would make the answer depend on registration order. What actually closes that shape is the #1299 scope narrowing above, which reaches the same consultors through the call site: reverting both withholdings leaves every suite and all 224 conformance programs green. They are kept, and pinned structurally on the tables they act on with one cell each, because nothing but those four consumers' current internals stops any of them from picking a winner. - **A module's data type no longer empties a prelude one out of every other namespace, and the shape that silently dropped functions is now an error** ([#1277](https://github.com/aallan/vera/issues/1277)). Codegen keeps ONE flat `_adt_layouts` map while the checker gives every namespace the prelude's data types from the start, and the two halves of that gap failed differently. **Membership**: `_adt_members_in_scope` recovered global infrastructure by SUBTRACTING what the namespaces declare from the registered layouts, which is sound only while "declared by a namespace" and "global infrastructure" are disjoint — and §8.4.1 makes them overlap on purpose, since the prelude's data types are ordinary public declarations a program may name and shadow. So one file's `data Json` removed `Json` from the member set of every OTHER namespace, including the entry program's, while the checker's `TypeEnv` carried it in all of them; measured as a straight disagreement, `Json` a data type in a module's namespace for the checker and not for codegen. The Pass-0.5 built-in snapshot unioned in as a floor could not protect the four demand-injected prelude ADTs, because it is taken before Pass 1.2 injects them — the same asymmetry [#1253](https://github.com/aallan/vera/issues/1253) fixed, one layer down. The floor is now stated positively rather than recovered by elimination: `vera.prelude.prelude_adt_names()` parses the prelude's own data blocks with the same parser `inject_prelude` uses, so a new prelude ADT joins the set by being written, and a differential holds the two against each other; the cached `prelude_data_decls()` behind it hands back a read-only mapping, since one cached object is shared by every caller in the process. Scoping the subtraction per namespace instead — the other direction the issue left open — is refuted by `tests/test_adt_membership_scope_1253.py`, which it re-opens: a sibling module's ADT would become infrastructure for every namespace but its own. **Contention**: where a MODULE declares one of the prelude's data type names with a DIFFERENT SHAPE and the prelude is also compiling its own, the two contend for the one layout slot and the module's wins. The prelude's ADT was then never registered, its own combinators hit `unknown constructor` (an `[E602]` inside ``), and every user function touching the type was dropped behind an `[E620]` cascade — all of it reported as WARNINGS, so a `vera check`-green program compiled with **exit 0** to a module with a function silently missing from its exports, and nothing named the declaration that caused it. That is now **E621**, an error located at the module's declaration in the module's own file, refused by the same Pass-1.9 severity gate `E608`/`E609`/`E610` use. It covers all eight of the prelude's data types, which required reading the DECLARATIONS rather than the registered layouts: the layout harvest skips a built-in name outright, because the throwaway registrar holds `Option`, `Result`, `Ordering` and `UrlParts` for every module whether it declares them or not, so a layout-keyed rail saw `data Json` and never `data Option`. The two halves differ only in when the prelude is present — the demand-injected four not until the entry program uses them, the always-injected four in every program — so a differently-shaped module `data Ordering` contends unconditionally, which upgrades that shape from an `[E602]`/`[E620]` cascade to one instruction. What decides contention is the two declarations' SHAPES: the same constructors, in the same order (the tag is the position), with the same field types, type parameters compared positionally — and each declaration's field types are resolved through the alias maps of the namespace it was WRITTEN in, the module's own for the module's declaration and none at all for the prelude's. Both halves of that are load-bearing. A module that restates the prelude's type through its own alias (`type Payload = String;`) is still a restatement, and comparing raw spellings refused it. Resolving the prelude's spelling through a module's aliases would go wrong the other way: `type Array = Int;` in a module makes its `JArray(Array)` an `Int` field, and with both sides resolved the two keys collapse — which is how that program compiles today, with the module's layout in the slot, the entry's `json_array_length` reading it, and no diagnostic at all. A module that restates the prelude's type shares the one layout — measured legal for all eight, and kept legal, which is what stops the rail from becoming the reservation §8.4.1 forbids. That is not a hypothetical: `examples/vera/collections.vera` declares `public data Option { None, Some(T) }` and `examples/modules.vera` imports it, so a rail that fired on the name alone refuses a shipped example — `vera compile` on `examples/modules.vera` returns E621 under that mutation, which `scripts/check_e602_clean.py` catches as a `COMPILE_ERROR` (measured; `check_examples.py` does not, running only `check` and `verify`). The rail's first form refused four of the synthetic restatements. Every declaring module is asked, not the first: a library that restates the prelude's `Ordering` otherwise answered for a sibling declaring a different one, so with the restating module imported first the sibling's contention went unseen — check-green, exit 0, the caller silently dropped — and the reverse import order caught it. An order-dependent rail is not a rail, and the battery now carries both orders. The module-versus-module pair for a name the prelude does NOT provide stays E609's, which a control pins. The acceptance battery is a parameterized test over all eight names in both shapes plus the restatement control, asserting that no cell reports `[E602]`/`[E620]` and that no zero-exit compile is missing a function, so the four-of-eight coverage the rail started with cannot return silently; `ch08_module_prelude_adt_contention_rejected` pins the same refusal at conformance level, paired with the positive that imports the same module and never names the type. The conformance manifest gained an `expected_error_stage` key for it (`"check"`, the default, or `"compile"`): a compile-stage negative asserts that the program type-checks CLEANLY and is then refused by `vera compile` with the declared code, which is the property a codegen-phase diagnostic exists for and which the check-only negative path could not express. Reserving the prelude's names is not the fix and is not done: §8.4.1 forbids it. - **The prelude's declaration-index block no longer depends on what the main file declares** ([#1287](https://github.com/aallan/vera/issues/1287)). `_stamp_decl_order` guarded its PRELUDE write on `_decl_order`, the ACTIVE (main-file) namespace — but `_prelude_decl_order` is not a namespace: `_module_alias_scope` builds every module's index space as `{**prelude, **module_own}`, so it is the base layer under all of them and its contents are a fact about what `inject_prelude` laid down. A main-file `type Option = Int` is accepted (§8.4.1 again) and, being an alias rather than a `data`, does not suppress the prelude's own `data Option`, so the guard fired on the prelude stamp: `Option` was left out of the block entirely, and every later prelude declaration shifted one place earlier because the skipped stamp never advanced the counter. Inside a module namespace the prelude's `Option` then reached `AliasEnv.data_types` at `_BUILTIN_DECL_INDEX` — below `_PRELUDE_DECL_BASE`, so ordered ahead of every other prelude declaration rather than among them — which is exactly the cross-namespace leak `_decl_order` and `_module_decl_order` were split apart to prevent. Latent at emission: that map changes a rendering only for `Decimal` and the single `REMOVED_ALIASES` entry `Float`, and no prelude ADT is either, so no WAT moves; the defect is the wrong value reaching the consumer. The prelude write is now unconditional and the ACTIVE space still takes the main file's stamp, so the shadow keeps winning its own namespace. Pinned as an invariance — the same program with and without the shadowing alias must stamp an identical prelude block — with the main-namespace control that a fix stamping `_decl_order` unconditionally would fail. diff --git a/CLAUDE.md b/CLAUDE.md index 193618e1..345faff2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,7 +130,7 @@ Before changing code — **adding or removing** — write the test that proves y ## What not to break - Pre-commit hooks run mypy + pytest + conformance suite + example validation on every commit -- All 229 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first +- All 229 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first - All 42 examples in `examples/` must pass `vera check` and `vera verify` - Version must stay in sync across `pyproject.toml`, `vera/__init__.py`, `docs/index.html`, `README.md`, and `uv.lock` (gated by `scripts/check_version_sync.py`); CHANGELOG.md must also carry a matching `## [X.Y.Z]` section - All tests must pass: `pytest tests/ -v` diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index b4d84323..17a93286 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -8,6 +8,7 @@ Defects in shipped compiler, runtime, or tooling behaviour — this table matche | Bug | Issue | |-----|-------| +| `E609` refuses two modules' same-named `data` declarations, and `E610` two modules' same-named **constructors**, by DECLARATION — consulting neither visibility nor the importing namespace's import filter nor local shadowing — the over-breadth [#1281](https://github.com/aallan/vera/issues/1281) removed from the function-side twin `E608`, still in place on the data side. Three remedies were measured against a diamond where `liba` and `libb` both export `public data Shape`, and all three fail identically: **narrowing** the second import so it no longer supplies the type, **declaring** `Shape` in the importing module (which spec §8.5.2 makes shadow both imports), and marking one module's declaration **`private`** (which exports nothing at all) each leave `vera check` and `vera verify` green and then die at `vera compile` with `[E609]` located at line 0 of the entry file, naming modules the entry never imports. `E610` behaves identically on its own axis, and its shape shows the collision is not about the type name at all: `liba` exporting `public data Alpha { Sq(Int), … }` beside `libb` exporting `public data Beta { Sq(Bool), … }` — two DIFFERENT types sharing only the constructor `Sq` — is `[E610]` at compile, and the same three remedies fail it in the same way (narrow the second import, declare a local `data Own { Sq(Int), … }`, or make one supplier `private`: each is check-green and verify-green and each is still `[E610]` at `vera compile`). Renaming in one of the source modules is therefore the only remedy for either code, which is why [#1304](https://github.com/aallan/vera/issues/1304)'s `E156`/`E157` prescribe renaming where their function-side sibling `E155` can offer a selective import or a local declaration — and why spec §8.5.4's "constructor names follow the same shadowing rules as function names" holds of resolution but not of compilation. Sibling of [#1312](https://github.com/aallan/vera/issues/1312): both are the flat namespace's data-collision rails mishandling declarations that occupy distinct identities. Fix direction: give `E609`/`E610` the #1281 treatment — read the same per-owner classification the clone namespace already does, so a declaration no importing namespace can name does not collide with one it can. | [#1317](https://github.com/aallan/vera/issues/1317) | | An entry-file `data` declaration and a module's of the same name silently drop the caller when their shapes differ. `private data Json { JMine(Int) }` in the entry beside `private data Json { JBlob(Int) }` in an imported module is `vera check`-green and then compiles with **exit 0**, `ok: true`, and nothing but `[E602]`/`[E620]` **warnings** — and the entry's `main`, which calls the module's function, is absent from the exports (measured: `exports == ['consume']`, the one function that touches only the entry's own declaration). The artifact loads and simply lacks its entry point. The flat namespace holds one layout per name and the entry's declaration takes it — Pass 1 registers the main file's `data` over the Pass-0.5 module harvest, which only `setdefault`s — so the module's own constructors become `unknown constructor` inside its own bodies. Same one-layout-per-name defect as [#1277](https://github.com/aallan/vera/issues/1277) and **not** closed by its E621 rail, for a structural reason rather than an oversight: that rail compares a PRELUDE declaration against a module's, and an entry-file declaration *suppresses* the prelude's injection outright, so no prelude declaration ever reaches Pass 1.2 and the contending pair here — entry versus module — is one the rail cannot be asked about. Behaviourally identical at `8e08ec90` and after #1277's fix, so pre-existing rather than introduced. Fix direction: put the entry-versus-module pair through the same contention check, reusing the machinery #1277 installed — `vera/prelude.py`'s `data_decl_shape` already decides whether two declarations of one name can share a layout (it is what keeps a module's restatement of a prelude type legal), and the conformance manifest's `expected_error_stage: "compile"` already expresses a check-green/compile-refused negative — leaving the ruling: a new code, or E609 widened from module-versus-module to take the entry file as a namespace, located at both declarations. | [#1312](https://github.com/aallan/vera/issues/1312) | | A module-declared generic instantiated at a type that comes from an EFFECT-OPERATION result never registers its clone. Codegen reports a compilation note (`call target 'mod$$$' not registered`) and silently drops the calling function, so the emitted module has no `main` — from a program `vera check` and `vera verify` both pass. Two conditions, bisected and both necessary: the generic is declared in a **module** (so its clone is the `mod$`-mangled form) and its type argument is inferred from an effect operation's result. Neither nesting under a `where` helper nor `private` visibility is needed — `public` fails identically. Controls that pass: a literal type argument, a type argument from a plain module-function result, and the identical shape with the generic declared in the entry file. This is the `mod$`-mangled REGISTRATION path for module generics (the #1274/#1281 machinery), distinct from #1299's scope-table divergence — measured identical on `release/v0.1.12`'s base and on the #1299/#1281 branch, so that fix neither causes it nor changes it. | [#1310](https://github.com/aallan/vera/issues/1310) | | A `type` alias whose name is also a registered ADT's compiles at the ADT's **pointer width**, not the alias body's. `type Option = Int;` with a `@Option` parameter is `vera check`-green and `vera verify`-green (4 Tier 1) and then dies at `vera run` with `Invalid input WebAssembly code at offset 79: type mismatch: expected i64, found i32`: codegen emits `(param $p0 i32)`, the ADT's heap pointer, where the checker resolved the alias body and typed the slot `Int`. Single file, no modules, and the byte-identical program with the alias renamed `Widget` emits `(param $p0 i64)` and prints 42 — so it is the collision with a registered ADT name, not aliases in general, and it is reachable for every name in `_adt_layouts`: the built-ins `_register_builtin_adts` installs as much as the prelude's. **Loud only by luck of the widths.** `type Ordering = Bool` runs and answers correctly because the alias body and the ADT pointer are both i32, so at matching widths the same resolution disagreement raises no WebAssembly error at all and the body reads a pointer as its aliased value — a silent wrong answer from a check-green, verify-green program is the class this row tracks, and the i64/i32 case above is merely the arm that happens to be caught by validation. Spec §8.4.1 permits the declaration — the prelude's and the built-in data types are ordinary declarations a program may name and shadow, the reserved namespace being the `Vera` prefix alone (E154) — so the fix is to make codegen agree with the checker rather than to reject the program. `vera/naming.py`'s `_resolve_named` already states the branch order (type parameter → primitive → alias, arity-checked → declared ADT); fix direction: route the WASM-width derivation through that one resolver instead of consulting `_adt_layouts` ahead of the alias maps. | [#1309](https://github.com/aallan/vera/issues/1309) | diff --git a/ROADMAP.md b/ROADMAP.md index 754c8cab..68919a7a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -137,7 +137,7 @@ Beyond the staged sprints — grouped by arc, each pulled forward by its trigger **Concurrency and WASI** — [#406](https://github.com/aallan/vera/issues/406) WASI 0.3 native async (gated on wasmtime-py exposing component async), [#853](https://github.com/aallan/vera/issues/853) extend wasi-p2 beyond IO+Random (Http via `wasi:http` outgoing-handler, streaming filesystem, sockets), [#270](https://github.com/aallan/vera/issues/270) `handle[Async]` scheduling strategies, [#227](https://github.com/aallan/vera/issues/227) timeout/cancellation effects, [#228](https://github.com/aallan/vera/issues/228) WebSocket/SSE, [#770](https://github.com/aallan/vera/issues/770) non-blocking / timed stdin, [#844](https://github.com/aallan/vera/issues/844) advisory diagnostic for shape-unfusable `async` arguments. -**Modules and ecosystem** — [#1304](https://github.com/aallan/vera/issues/1304) settle which import supplies a bare name when two of them do — spec §8.5 defines no order, and the checker's pick is a set-iteration artefact that flips between runs of the identical program rather than an order at all ([measured](https://github.com/aallan/vera/issues/1304#issuecomment-5289022950): six consecutive `vera check` runs of one file, stable under a fixed `PYTHONHASHSEED` but tracking module-name hash order rather than import position), with E608 refusing the shape at compile so the flap is observable only at `vera check`, [#187](https://github.com/aallan/vera/issues/187) module-qualified call disambiguation → [#127](https://github.com/aallan/vera/issues/127) module re-exports, [#130](https://github.com/aallan/vera/issues/130) package system and registry, [#163](https://github.com/aallan/vera/issues/163) standalone WASM runtime package, [#238](https://github.com/aallan/vera/issues/238) Component Model interop, [#56](https://github.com/aallan/vera/issues/56) incremental compilation, [#294](https://github.com/aallan/vera/issues/294) effect row variable unification, [#785](https://github.com/aallan/vera/issues/785) GitHits MCP (bookmark; trial at the next dependency-facing milestone). +**Modules and ecosystem** — [#187](https://github.com/aallan/vera/issues/187) module-qualified call disambiguation for data types and constructors (the function namespace is settled by spec §8.5.2.2's refusal) → [#127](https://github.com/aallan/vera/issues/127) module re-exports, [#130](https://github.com/aallan/vera/issues/130) package system and registry, [#163](https://github.com/aallan/vera/issues/163) standalone WASM runtime package, [#238](https://github.com/aallan/vera/issues/238) Component Model interop, [#56](https://github.com/aallan/vera/issues/56) incremental compilation, [#294](https://github.com/aallan/vera/issues/294) effect row variable unification, [#785](https://github.com/aallan/vera/issues/785) GitHits MCP (bookmark; trial at the next dependency-facing milestone). **Standard library long tail** — [#367](https://github.com/aallan/vera/issues/367) Markdown extractors, [#368](https://github.com/aallan/vera/issues/368) HTML accessors, [#507](https://github.com/aallan/vera/issues/507) ability-dispatched array operations, [#509](https://github.com/aallan/vera/issues/509) Unicode-aware string built-ins phase 2, [#1143](https://github.com/aallan/vera/issues/1143) `` effect phases 2–3 — named columns (via Map), typed rows (via JSON), and further backends. diff --git a/SKILL.md b/SKILL.md index a85ecd10..dff9d25f 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1951,15 +1951,15 @@ Imported function contracts are verified at call sites by the SMT solver. Precon Cross-module compilation uses a flattening strategy: imported function bodies are compiled into the same WASM module as the importing program. The result is a single self-contained `.wasm` binary. Imported functions are internal (not exported); only the importing program's `public` functions are WASM exports. -If two imported modules define a function, data type, or constructor with the same name, the compiler reports an error (E608/E609/E610) listing both conflicting modules. Rename one of the conflicting declarations in the source module to resolve the collision. Local definitions shadow imported names without error. +If two of a namespace's imports supply the same bare name, `vera check` refuses it — E155 (function), E156 (data type), E157 (constructor) — reported at the second supplying import, in whichever file holds the clash. For a **function** name, resolve it either by narrowing one import (`import m(other_name);`) so a single supplier remains, or by declaring the name locally and reaching the imports with the module-qualified form (`m::name(...)`). For a **data type** or **constructor** name, rename the declaration in one of the two modules: compilation refuses two modules' same-named data declarations however the importer filters or shadows them, so no import-side change resolves those. The compile-time rails E608/E609/E610 remain as the backstop and refuse a wider set — any two modules in one program, not just two suppliers of one namespace. Local definitions shadow imported names without error. A module's data type may also collide with one the prelude provides (`Option`, `Result`, `Ordering`, `UrlParts`, `Json`, `HtmlNode`, `Request`, `Response`). One name carries one layout in the compiled program, so the two contend when their shapes differ — different constructors, a different constructor order, different field types, or a different number of type parameters (their names are free, since they are matched by position). The compiler then reports **E621** at the module's declaration; rename it there, or give it the prelude's shape. A module declaring `data Json` is legal on its own, because the prelude injects `Json` only when the entry program uses it; `Option`, `Result`, `Ordering` and `UrlParts` are in every program, so a differently-shaped module declaration of one of those always contends. Declaring the type in the **entry** file instead suppresses the prelude's own, so it never contends with the prelude — but it does not settle a clash with a module that also declares the name, which is a separate pair the compiler does not yet arbitrate ([#1312](https://github.com/aallan/vera/issues/1312)). Type aliases and effect declarations are module-local and cannot be imported. If another module needs the same alias or effect, it must declare its own copy. -Module-qualified calls use `::` between the module path and the function name: `vera.math::magnitude(42)`. The dot-separated path identifies the module and `::` separates it from the function name. This syntax can be used anywhere a function call is valid, and always resolves against the specific module's public declarations — it is not affected by local shadowing. Note: module-qualified calls (`math::magnitude(42)`) are available for readability but do not yet resolve name collisions in flat compilation — the compiler will still report a collision error. A future version will support qualified-call disambiguation via name mangling. +Module-qualified calls use `::` between the module path and the function name: `vera.math::magnitude(42)`. The dot-separated path identifies the module and `::` separates it from the function name. This syntax can be used anywhere a function call is valid, and always resolves against the specific module's public declarations — it is not affected by local shadowing. Note: qualification names a call site, not an import list, so writing a clashing call as `m::name(42)` does not by itself lift E155 — the ambiguity is in the namespace. It is how you reach both suppliers once a local declaration or a narrowed import has settled which one owns the bare name. -There is no import aliasing (`import m(abs as math_abs)`) and no wildcard exclusion (`import m hiding(x)`). These are intentional design decisions, not limitations. When names clash across modules, rename the conflicting declaration in one of the source modules. This preserves the one-canonical-form principle — every function has exactly one name. +There is no import aliasing (`import m(abs as math_abs)`) and no wildcard exclusion (`import m hiding(x)`). These are intentional design decisions, not limitations. When a function name clashes across two imports, narrow one import or declare the name locally and qualify the rest; when a data type or constructor name clashes, rename it in one of the source modules. This preserves the one-canonical-form principle — every declaration has exactly one name. There are no raw strings (`r"..."`) or multi-line string literals. Use escape sequences for special characters; this is by design — alternative string syntaxes would create two representations for the same value. @@ -2285,7 +2285,7 @@ import vera.math(magnitude); vera.math::magnitude(-5) ``` -Note: if two imported modules define the same name, the compiler reports a collision error (E608/E609/E610). Rename the conflicting declaration in one of the source modules. +Note: if two of a namespace's imports supply the same bare name, `vera check` refuses it (E155 function / E156 data type / E157 constructor). Narrow one import or declare a function name locally; rename a clashing data type or constructor in one of the source modules. ### Trying to use wildcard exclusion diff --git a/TESTING.md b/TESTING.md index 120ed961..05cca78d 100644 --- a/TESTING.md +++ b/TESTING.md @@ -84,7 +84,8 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_import_visibility_entry_point_1244.py` | 6 | 269 | #1244 — `vera check` reports the same diagnostics whether it was given a module or a file that imports it. Registration alone says what a module DECLARES; the importer never checked its bodies, so a name a module never imported was rejected standalone (E200, §8.5.1) and accepted in silence through an importer. Written as EQUALITY between the two entry points rather than as "the importer warns", because the property is agreement — a future change making the standalone verdict lenient would satisfy a one-sided assertion and must fail here on the standalone leg. Six cases: the leaked unimported name, the honest control that imports what it uses (green before and after, so the new body check is the visibility rule rather than a blanket rejection of cross-module programs), the issue's type-error-through-importer shape (an `@Int` call bound to a `@Bool` slot: check-clean, Tier-1, failing at compile), a diamond proving each module is reported ONCE (the body check is memoised by path across nested checkers), and both entry points into an import cycle proving the memo terminates it | | `test_clone_body_declaring_module_1241_1243.py` | 5 | 344 | #1241 + #1243 — an imported generic's clone body resolves its bare calls in the DECLARING module, on both sides. The verifier's lexical lookup fell through to the importer's registry (`_declaring_module_scope` swapped the naming env, source and file but not the function registry) and codegen's clone-emission door was the one door that did not thread the module's intra-rename map, so `glib`'s `gen` called the importer's `need`. The two halves are one routing rule, and the tests are written so neither passes alone: each case asserts the `vera verify` verdict AND the `vera run` value together, so the verifier half alone (which makes verify clean while the compiled program still traps on the postcondition it just proved — the measured false Tier-1) fails the same test the codegen half alone (right value, verify still refusing) fails. Every expected value comes from the module verified and run STANDALONE, never from what the importer produces. Shapes: a private direct callee, a two-hop private chain, the type-discriminating pair (`@Int` vs `@Bool` — check-green source that emitted invalid WASM), and the unshadowed-callee control that was correct before and after, which is what pins the defect to the SHADOWED name rather than to cross-module calls in general | | `test_module_generic_namespace_1274.py` | 24 | 1,015 | #1274 — a module generic that does not own the importer's bare name is reached under `mod$$name`. Pre-fix only PRIVATE module generics were routed that way (#1000), so a PUBLIC one collided with the importer's same-named generic in the clone-name space: both files' `gen2` mangled to one `gen2$Bool`, one overwrote the other, and the module's own body ran the importer's — a **false Tier-1** (`check`/`verify` clean, the module's proved `ensures` violated at run: 999 where the declaring module answers 111). The full visibility matrix (module generic × importer generic), the import-filter dimension (out-of-filter, in-filter, wildcard), the unshadowed-out-of-filter cell that assembled to `unknown func $gen2`, and a type-discriminating shape whose two clones have different WAT result types. Each cell asserts the verify VERDICT and the runtime VALUE together in one test — a clean verify beside a violated postcondition IS the bug, so splitting them across sibling tests would let it hide — against the standalone library as oracle, and re-checks that the importer's own generic still answers its own value. The per-module both-sides differential lives in `test_monomorphize_differential.py`. Two further families joined after the adversarial round: the module→module **hop** — a module's bare call to a DIFFERENT module's qualified-only generic, which the per-module classification never rerouted, in both a LOUD spelling (a contract pins the answer, so a captured call traps) and a SILENT one (every contract admits both answers, so only the value distinguishes them) plus the two-hop shape where the entry never imports the declaring module at all; and the **shared-input** pair, which pins that the two sides compute the importer's occupied bare names identically — codegen reads them after Pass 0's helper renames, the verifier from the pre-transform AST, and a non-generic `where`-helper named `gen2` made the same imported generic bare-name-owning on one side and qualified-only on the other. The idempotence of that derivation is asserted directly across BOTH Pass-0 transforms and their composition — over a fixture carrying every helper shape it distinguishes (non-generic under non-generic, under a generic parent, under a generic helper, and a generic helper), since a fixture missing one would let a partial assertion look total — with each shape's membership pinned individually beside it, because idempotence alone would hold for a derivation that answered the same WRONG set every time. Two more families close the visibility dimension: the **transitive** one, driven through the production `ModuleResolver` (a hand-built `ResolvedModule` defaults `direct=True` and would never reach the path), asserting that a module reached only transitively has ALL its generics qualified-only — the entry's namespace does not hold them at all; and the **user-written qualified call** (`deep::gen(true)` where the importer declares its own `gen`), which must key its instantiation to the module's declaration rather than to whoever owns the bare name | -| `test_module_generic_collision_1281.py` | 20 | 778 | #1281 — E608 must not refuse two modules' PROVABLY DISTINCT generics. A generic emits nothing under its bare name, and since #1274 its clones live in a namespace chosen per OWNER, so the diamond (`base` public, `mid1` private, both named `gen`) cannot overwrite anything — and was refused outright, with `vera verify` returning rc=0 beside the refusal. Each door now answers its own module's generic (555 + 111) against the standalone oracles, and the emitted module carries `mod$…$mid1$gen$Bool` and `mod$…$base$gen$Bool` with nothing in the entry's bare clone namespace. The relaxation is gated on three conditions, each with its own cell: both declarations are top-level generics (a generic beside a non-generic keeps the refusal), at most one owns the bare name (asked of the predicate directly, since end to end the ambiguity gate catches that shape first), and no namespace can name both — a module importing two dependencies that each export `gen` resolves its own bare call to one of them, and neither spec §8.5 nor the checker says which: the checker's pick is a set-iteration artefact, and over eight runs of one unchanged program the type oracle accepted four times and was rejected four times (stable under a fixed `PYTHONHASHSEED`, varying with the seed, and identical whichever textual order the imports are written in), while codegen's reroute map is positional last-wins. Compiling the shape would pick a body by coin-flip, so it keeps its loud refusal (issue 1304). A namespace that declares its own `gen` is not ambiguous however many dependencies export one (§8.5.2). The registration half — a qualified-only generic contributing no bare `_fn_sigs` or `_fn_ret_type_exprs` entry — is pinned by two STRUCTURAL cells, one per table, and its docstring says why: with #1299's scope narrowing in place both withholdings are defence in depth, reverting them leaves every suite and all 229 conformance programs green, and they are kept only because four consumers read those tables per NAME and nothing but their current internals stops each from picking one | +| `test_ambiguous_import_refusal_1304.py` | 37 | 1,109 | #1304 — two imports supplying one bare name are refused, in every namespace. Spec §8.5 ordered a local declaration against an import and gave the qualified form for a clash it hides, but defined no order between two IMPORTS of one name, and neither did the implementation: a module importing two dependencies that each export `forall fn gen` — one `@Int`-returning, one `@Bool` — bound its bare call to whichever supplier a set of module paths yielded first, so one unchanged file was check-green on one run and `[E121] body has type Bool` on the next (at the branch point: accepted on hash seeds 0, 2 and 3, rejected on 1, 4, 5, 6 and 7). The load-bearing cells are the DETERMINISM ones — each import order checked in four fresh subprocesses under four `PYTHONHASHSEED` values, asserting one byte-identical verdict including message and location, which the base tree cannot satisfy and which a merely deterministic PICK would also fail (the refusal is what removes the choice). Around them: the refusal is definition-gated like the E608 rail it generalises, so an unused clash is still refused and swapping a bare call for the qualified form does not lift it; the two escape hatches — a local declaration (§8.5.2) and a selective import — are asserted to their RUNTIME VALUE, since a disambiguation resolving to the wrong supplier is silent at check and wrong at run; four non-ambiguous controls (one supplier, disjoint names, a private namesake, an out-of-filter namesake) hold the refusal to bare-name ambiguity; and the emitted code is held to a typecheck-phase range, because reusing a codegen code would carry #1304's own complaint — a scope question enforced at the wrong layer — into the fix. A subprocess canary pins that every fresh interpreter measures this checkout | +| `test_module_generic_collision_1281.py` | 20 | 797 | #1281 — E608 must not refuse two modules' PROVABLY DISTINCT generics. A generic emits nothing under its bare name, and since #1274 its clones live in a namespace chosen per OWNER, so the diamond (`base` public, `mid1` private, both named `gen`) cannot overwrite anything — and was refused outright, with `vera verify` returning rc=0 beside the refusal. Each door now answers its own module's generic (555 + 111) against the standalone oracles, and the emitted module carries `mod$…$mid1$gen$Bool` and `mod$…$base$gen$Bool` with nothing in the entry's bare clone namespace. The relaxation is gated on three conditions, each with its own cell: both declarations are top-level generics (a generic beside a non-generic keeps the refusal), at most one owns the bare name (asked of the predicate directly, since end to end the ambiguity gate catches that shape first), and no namespace can name both — a module importing two dependencies that each export `gen` would resolve its own bare call to one of them, and spec §8.5 refuses the name outright rather than ordering the two imports (issue 1304). The CHECKER reports that (E155) and this rail is its backstop, so both cells drive the shape through `build_multi_module_past_check` and assert both layers: a rail no test can reach is one that can rot into a relaxation nobody measures. A namespace that declares its own `gen` is not ambiguous however many dependencies export one (§8.5.2). The registration half — a qualified-only generic contributing no bare `_fn_sigs` or `_fn_ret_type_exprs` entry — is pinned by two STRUCTURAL cells, one per table, and its docstring says why: with #1299's scope narrowing in place both withholdings are defence in depth, reverting them leaves every suite and all 226 conformance programs green, and they are kept only because four consumers read those tables per NAME and nothing but their current internals stops each from picking one | | `test_lexical_fn_scope_1299.py` | 56 | 1,554 | #1299 — codegen's bare-call ownership table must be the CALL SITE's lexical scope. The #1284 predicate is one rule over two tables, and codegen's was `set(_fn_sigs)`: every symbol the whole compilation absorbed, including names the compiling body cannot see, so a bare `get(())` the checker resolved to a `State` operation was lowered as a call to some other declaration. Four routes, all check-green — an imported module's **private** `get`, a **public** one a selective import excludes, a `where` helper of a **`forall` parent** (which keeps a bare key beside its clone-qualified one where a non-generic parent's does not), and the ability operation `show`, which E151 does not reserve and which reaches the same table through the INTRINSIC gate rather than the op one. Where the widths agreed the module loaded and answered the invisible declaration's value (7007 for the cell's 42007); where they differed it failed to load; the generic-`where` route is always loud (`unknown func $get`). Every expected value is the checker's, PROVEN by a type oracle rather than assumed — the invisible `get` returns `@Bool` while the caller returns `@Int` from it and checks green — and each route carries a rename control. The visibility matrix (public/private × in-filter/excluded/wildcard × shadowed/unshadowed × direct/transitive) asserts the verify verdict and the runtime value together per cell. The two directions are pinned at once: the sibling loses the name, the generic TEMPLATE keeps it (asserted on the emitted instruction stream, since monomorphization supersedes the template and a value assertion would be green either way), and a lifted closure — compiled through its own `WasmContext` — inherits its parent's scope. Four table invariants sit beside them: the scoped set is a subset of the registry, every `$`-bearing key stays in it, prelude names stay in it, and every emission door supplies a declaration its own helpers | | `test_phantom_generic_instances_1271.py` | 14 | 358 | #1271 — discovery inside a still-generic scope must not instantiate a callee at an ENCLOSING scope's type VARIABLE. `pick(@U.1, @U.0)` inside `forall fn helper` bound `pick`'s variable to the NAME `U`, so a `pick$U` clone was emitted whose parameter has no WASM type and which the compilability pass then skipped with a loud `[E604]` — the noise that kept #1223's shapes out of the conformance suite. Drives the four #1223 fixtures plus a mutual-recursion shape (two sibling generic helpers under a generic parent, whose phantoms include one arriving through a callee's declared RETURN type, `leaf$W`), asserting on ONE compile that no clone is keyed by a type variable, that no E602/E604/E605 skip is emitted, AND that the genuinely concrete clone is still there — the third assertion being what separates the filter from an over-filter that would take the real instantiation with it. Plus the **primitive-spelled binder** matrix (`forall`, ``, ``, ``), each row instantiating a sibling at exactly the type its binder is spelled like — a shared `idw(5)` would have let every row but `Int` pass for free — asserted on the clone set AND on the program still running; with the `Q`-binder control that keeps a genuine type variable filtered, so the fix cannot degenerate into "never filter". That control CREATES a live phantom candidate — a generic helper under a generic parent, handing its callee an argument typed by its own binder — because a control that merely fails to create one holds under any filter including none; mutation-checked by disabling the filter, which turns it red | | `test_handle_exn_divergent_result_1276.py` | 10 | 391 | #1276 — a `handle[Exn]` whose clause body AND handled body both diverge emitted a result-LESS `block` into a result-expecting context: check-green, verify-green, rejected at load with `type mismatch: expected i64 but nothing on stack`. Four divergent shapes (the issue's Int rethrow, the `Byte` payload spelling #1269 unmasked, a three-deep rethrow chain, and a clause diverging through both arms of an `if`), each asserted on valid WASM AND on the observable — the OUTER handler's clause value, 1000. Paired with the Unit TWIN, which infers `None` for the same reason but DOES complete: it must keep running and its WAT must contain no `unreachable` at all. The pairing is the point — `result_wt is None` means two things wanting opposite lowerings, and a fix that terminated both would trap a program that runs. The MIRROR family covers a clause that throws on one path and COMPLETES on the other (`if` and `match` spellings), where the inference read only the `then` branch / arm 0, answered `None`, and left the completing path's value stranded in a result-less block; the `if` case appears twice with different thrown values so both the throwing and the completing path are exercised from one inference | @@ -252,15 +253,15 @@ Each conformance program declares the deepest pipeline stage it must pass: | Level | What it validates | Count | |-------|-------------------|------:| | `parse` | Source text is syntactically valid | 0 | -| `check` | Parses and type-checks cleanly | 41 | +| `check` | Parses and type-checks cleanly | 49 | | `verify` | Type-checks and all contracts verified by Z3 | 20 | -| `run` | Compiles to WASM and executes correctly | 168 | +| `run` | Compiles to WASM and executes correctly | 169 | -Almost all programs are at the `run` level — they compile and execute, producing correct results. Forty-one programs (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch03_typed_holes`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_cross_module_contracts_lib`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch07_bare_effect_op_rejected`, `ch08_circular_import`, `ch08_cross_module_generic_lib`, `ch08_module_generic_diamond_base`, `ch08_module_prelude_adt_contention_rejected`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_transitive_module_import_base`, `ch08_visibility_private`, `ch08_xmod_widen_lib`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_eq_non_derivable_rejected`, `ch09_http`, `ch09_inference`, `ch09_ord_adt_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) are at the `check` level. Thirty-two of them — `ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch07_bare_effect_op_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, and `ch02_alias_cycle_rejected` — are **negative tests** that assert a specific diagnostic (E206, E135, E183, E201, E127, E153, E153, E153, E130, E130, E174, E182, E217, E011, E154, E154, E154, E154, E154, E154, E150, E152, E151, E242, E243, E207, E208, E208, E209, E128, E336, and E132 respectively) via the manifest's `expected_error` field. One more — `ch08_module_prelude_adt_contention_rejected` — is a negative at the `compile` stage rather than at `check`: it carries `expected_error_stage: "compile"` beside `expected_error: E621`, so the harness asserts it type-checks CLEANLY and is then refused by `vera compile` with that code, which is the property a codegen-phase diagnostic exists for. `ch09_http` and `ch09_inference` are environment-gated (network / API key). Twenty programs (`ch03_slot_let_chains`, `ch03_slot_noncommutative`, `ch04_nested_option_ctor`, `ch04_primitive_obligations`, `ch05_apply_fn_typing`, `ch06_adt_sort_disambiguation`, `ch07_cross_module_contracts`, `ch07_invisible_import_op_name_lib`, `ch07_io_read_char`, `ch07_io_sleep`, `ch07_random_effect`, `ch08_state_alias_module_table_lib`, `ch08_module_generic_diamond_mid1`, `ch08_module_generic_diamond_mid2`, `ch08_state_alias_per_module_lib`, `ch08_transitive_module_import_mid`, `ch09_http_server`, `ch09_invisible_import_ability_op_lib`, `ch09_math_builtins`, `ch09_nested_helper_family_op_name_lib`) are at the `verify` level, using Z3-provable contracts — a library module is pinned at the deepest level it reaches, so the two per-module alias-table libraries are verified rather than only checked. +Almost all programs are at the `run` level — they compile and execute, producing correct results. Forty-nine programs (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch03_typed_holes`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_cross_module_contracts_lib`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch07_bare_effect_op_rejected`, `ch08_ambiguous_import_adt_lib_bool`, `ch08_ambiguous_import_adt_lib_int`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_lib_bool`, `ch08_ambiguous_import_lib_int`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_cross_module_generic_lib`, `ch08_module_generic_diamond_base`, `ch08_module_prelude_adt_contention_rejected`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_transitive_module_import_base`, `ch08_visibility_private`, `ch08_xmod_widen_lib`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_eq_non_derivable_rejected`, `ch09_http`, `ch09_inference`, `ch09_ord_adt_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) are at the `check` level. Thirty-seven of them — `ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch07_bare_effect_op_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, and `ch02_alias_cycle_rejected` — are **negative tests** that assert a specific diagnostic (E206, E135, E183, E201, E127, E153, E153, E153, E130, E130, E174, E182, E217, E011, E154, E154, E154, E154, E154, E154, E150, E152, E151, E242, E243, E207, E208, E208, E209, E128, E336, and E132 respectively) via the manifest's `expected_error` field. One more — `ch08_module_prelude_adt_contention_rejected` — is a negative at the `compile` stage rather than at `check`: it carries `expected_error_stage: "compile"` beside `expected_error: E621`, so the harness asserts it type-checks CLEANLY and is then refused by `vera compile` with that code, which is the property a codegen-phase diagnostic exists for. `ch09_http` and `ch09_inference` are environment-gated (network / API key). Twenty programs (`ch03_slot_let_chains`, `ch03_slot_noncommutative`, `ch04_nested_option_ctor`, `ch04_primitive_obligations`, `ch05_apply_fn_typing`, `ch06_adt_sort_disambiguation`, `ch07_cross_module_contracts`, `ch07_invisible_import_op_name_lib`, `ch07_io_read_char`, `ch07_io_sleep`, `ch07_random_effect`, `ch08_state_alias_module_table_lib`, `ch08_module_generic_diamond_mid1`, `ch08_module_generic_diamond_mid2`, `ch08_state_alias_per_module_lib`, `ch08_transitive_module_import_mid`, `ch09_http_server`, `ch09_invisible_import_ability_op_lib`, `ch09_math_builtins`, `ch09_nested_helper_family_op_name_lib`) are at the `verify` level, using Z3-provable contracts — a library module is pinned at the deepest level it reaches, so the two per-module alias-table libraries are verified rather than only checked. ### Skipped tests -`pytest tests/ -v` skips 102 conformance-stage tests, and every one of them is the level rule: a program declared at `check` skips its `verify` and `run` stages, one declared at `verify` skips its `run` — 41 × 2 + 20, which is what the suite reports. The two tables below split those 102 by why the program sits at its level, not by how it skipped: 98 are pinned there by the feature under test, and 4 by an environment CI does not have. Each skip is listed once; the tables do not overlap. (The suite's remaining skips are platform- or tool-gated and documented beside the tests that declare them.) +`pytest tests/ -v` skips 118 conformance-stage tests, and every one of them is the level rule: a program declared at `check` skips its `verify` and `run` stages, one declared at `verify` skips its `run` — 49 × 2 + 20, which is what the suite reports. The two tables below split those 118 by why the program sits at its level, not by how it skipped: 98 are pinned there by the feature under test, and 4 by an environment CI does not have. Each skip is listed once; the tables do not overlap. (The suite's remaining skips are platform- or tool-gated and documented beside the tests that declare them.) **Level-limited skips** — the conformance framework only runs tests up to the declared level; stages beyond that level are automatically skipped. These are expected and correct. @@ -333,6 +334,22 @@ Almost all programs are at the `run` level — they compile and execute, produci | `test_run[ch08_reserved_vera_prefix_ability_rejected]` | `ch08_reserved_vera_prefix_ability_rejected.vera` | `check` | `run` | `check`-level negative test: no `run` stage | | `test_verify[ch08_reserved_vera_prefix_constructor_rejected]` | `ch08_reserved_vera_prefix_constructor_rejected.vera` | `check` | `verify` | `check`-level negative test (`expected_error: E154`): verify stage not run | | `test_run[ch08_reserved_vera_prefix_constructor_rejected]` | `ch08_reserved_vera_prefix_constructor_rejected.vera` | `check` | `run` | `check`-level negative test: no `run` stage | +| `test_verify[ch08_ambiguous_import_rejected]` | `ch08_ambiguous_import_rejected.vera` | `check` | `verify` | `check`-level negative test (`expected_error: E155`): verify stage not run | +| `test_run[ch08_ambiguous_import_rejected]` | `ch08_ambiguous_import_rejected.vera` | `check` | `run` | `check`-level negative test: no `run` stage | +| `test_verify[ch08_ambiguous_import_swapped_rejected]` | `ch08_ambiguous_import_swapped_rejected.vera` | `check` | `verify` | `check`-level negative test (`expected_error: E155`): verify stage not run | +| `test_run[ch08_ambiguous_import_swapped_rejected]` | `ch08_ambiguous_import_swapped_rejected.vera` | `check` | `run` | `check`-level negative test: no `run` stage | +| `test_verify[ch08_ambiguous_import_adt_lib_int]` | `ch08_ambiguous_import_adt_lib_int.vera` | `check` | `verify` | `check`-level library module: verify stage not run | +| `test_run[ch08_ambiguous_import_adt_lib_int]` | `ch08_ambiguous_import_adt_lib_int.vera` | `check` | `run` | `check`-level library module: no standalone `main` | +| `test_verify[ch08_ambiguous_import_adt_lib_bool]` | `ch08_ambiguous_import_adt_lib_bool.vera` | `check` | `verify` | `check`-level library module: verify stage not run | +| `test_run[ch08_ambiguous_import_adt_lib_bool]` | `ch08_ambiguous_import_adt_lib_bool.vera` | `check` | `run` | `check`-level library module: no standalone `main` | +| `test_verify[ch08_ambiguous_import_adt_rejected]` | `ch08_ambiguous_import_adt_rejected.vera` | `check` | `verify` | `check`-level negative test (`expected_error: E156`): verify stage not run | +| `test_run[ch08_ambiguous_import_adt_rejected]` | `ch08_ambiguous_import_adt_rejected.vera` | `check` | `run` | `check`-level negative test: no `run` stage | +| `test_verify[ch08_ambiguous_import_adt_swapped_rejected]` | `ch08_ambiguous_import_adt_swapped_rejected.vera` | `check` | `verify` | `check`-level negative test (`expected_error: E157`): verify stage not run | +| `test_run[ch08_ambiguous_import_adt_swapped_rejected]` | `ch08_ambiguous_import_adt_swapped_rejected.vera` | `check` | `run` | `check`-level negative test: no `run` stage | +| `test_verify[ch08_ambiguous_import_lib_int]` | `ch08_ambiguous_import_lib_int.vera` | `check` | `verify` | `check`-level library module: verify stage not run | +| `test_run[ch08_ambiguous_import_lib_int]` | `ch08_ambiguous_import_lib_int.vera` | `check` | `run` | `check`-level library module: no standalone `main` | +| `test_verify[ch08_ambiguous_import_lib_bool]` | `ch08_ambiguous_import_lib_bool.vera` | `check` | `verify` | `check`-level library module: verify stage not run | +| `test_run[ch08_ambiguous_import_lib_bool]` | `ch08_ambiguous_import_lib_bool.vera` | `check` | `run` | `check`-level library module: no standalone `main` | | `test_verify[ch08_cross_module_generic_lib]` | `ch08_cross_module_generic_lib.vera` | `check` | `verify` | `check`-level library module: verify stage not run | | `test_run[ch08_cross_module_generic_lib]` | `ch08_cross_module_generic_lib.vera` | `check` | `run` | `check`-level library module: no standalone `main` | | `test_verify[ch08_module_generic_diamond_base]` | `ch08_module_generic_diamond_base.vera` | `check` | `verify` | `check`-level library module: verify stage not run | diff --git a/docs/SKILL.md b/docs/SKILL.md index 8f38ec17..12aa3f86 100644 --- a/docs/SKILL.md +++ b/docs/SKILL.md @@ -1907,15 +1907,15 @@ Imported function contracts are verified at call sites by the SMT solver. Precon Cross-module compilation uses a flattening strategy: imported function bodies are compiled into the same WASM module as the importing program. The result is a single self-contained `.wasm` binary. Imported functions are internal (not exported); only the importing program's `public` functions are WASM exports. -If two imported modules define a function, data type, or constructor with the same name, the compiler reports an error (E608/E609/E610) listing both conflicting modules. Rename one of the conflicting declarations in the source module to resolve the collision. Local definitions shadow imported names without error. +If two of a namespace's imports supply the same bare name, `vera check` refuses it — E155 (function), E156 (data type), E157 (constructor) — reported at the second supplying import, in whichever file holds the clash. For a **function** name, resolve it either by narrowing one import (`import m(other_name);`) so a single supplier remains, or by declaring the name locally and reaching the imports with the module-qualified form (`m::name(...)`). For a **data type** or **constructor** name, rename the declaration in one of the two modules: compilation refuses two modules' same-named data declarations however the importer filters or shadows them, so no import-side change resolves those. The compile-time rails E608/E609/E610 remain as the backstop and refuse a wider set — any two modules in one program, not just two suppliers of one namespace. Local definitions shadow imported names without error. A module's data type may also collide with one the prelude provides (`Option`, `Result`, `Ordering`, `UrlParts`, `Json`, `HtmlNode`, `Request`, `Response`). One name carries one layout in the compiled program, so the two contend when their shapes differ — different constructors, a different constructor order, different field types, or a different number of type parameters (their names are free, since they are matched by position). The compiler then reports **E621** at the module's declaration; rename it there, or give it the prelude's shape. A module declaring `data Json` is legal on its own, because the prelude injects `Json` only when the entry program uses it; `Option`, `Result`, `Ordering` and `UrlParts` are in every program, so a differently-shaped module declaration of one of those always contends. Declaring the type in the **entry** file instead suppresses the prelude's own, so it never contends with the prelude — but it does not settle a clash with a module that also declares the name, which is a separate pair the compiler does not yet arbitrate ([#1312](https://github.com/aallan/vera/issues/1312)). Type aliases and effect declarations are module-local and cannot be imported. If another module needs the same alias or effect, it must declare its own copy. -Module-qualified calls use `::` between the module path and the function name: `vera.math::magnitude(42)`. The dot-separated path identifies the module and `::` separates it from the function name. This syntax can be used anywhere a function call is valid, and always resolves against the specific module's public declarations — it is not affected by local shadowing. Note: module-qualified calls (`math::magnitude(42)`) are available for readability but do not yet resolve name collisions in flat compilation — the compiler will still report a collision error. A future version will support qualified-call disambiguation via name mangling. +Module-qualified calls use `::` between the module path and the function name: `vera.math::magnitude(42)`. The dot-separated path identifies the module and `::` separates it from the function name. This syntax can be used anywhere a function call is valid, and always resolves against the specific module's public declarations — it is not affected by local shadowing. Note: qualification names a call site, not an import list, so writing a clashing call as `m::name(42)` does not by itself lift E155 — the ambiguity is in the namespace. It is how you reach both suppliers once a local declaration or a narrowed import has settled which one owns the bare name. -There is no import aliasing (`import m(abs as math_abs)`) and no wildcard exclusion (`import m hiding(x)`). These are intentional design decisions, not limitations. When names clash across modules, rename the conflicting declaration in one of the source modules. This preserves the one-canonical-form principle — every function has exactly one name. +There is no import aliasing (`import m(abs as math_abs)`) and no wildcard exclusion (`import m hiding(x)`). These are intentional design decisions, not limitations. When a function name clashes across two imports, narrow one import or declare the name locally and qualify the rest; when a data type or constructor name clashes, rename it in one of the source modules. This preserves the one-canonical-form principle — every declaration has exactly one name. There are no raw strings (`r"..."`) or multi-line string literals. Use escape sequences for special characters; this is by design — alternative string syntaxes would create two representations for the same value. @@ -2230,7 +2230,7 @@ import vera.math(magnitude); vera.math::magnitude(-5) ``` -Note: if two imported modules define the same name, the compiler reports a collision error (E608/E609/E610). Rename the conflicting declaration in one of the source modules. +Note: if two of a namespace's imports supply the same bare name, `vera check` refuses it (E155 function / E156 data type / E157 constructor). Narrow one import or declare a function name locally; rename a clashing data type or constructor in one of the source modules. ### Trying to use wildcard exclusion diff --git a/docs/index.html b/docs/index.html index 8c1613e0..8f9c5705 100644 --- a/docs/index.html +++ b/docs/index.html @@ -699,7 +699,7 @@

This page is also a machine-readable specificati Vera is under active development

- A complete compiler with 164 built-in functions, ten algebraic effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge), contract-driven testing via Z3, a language server with agent-facing proof deltas, and a 14-chapter specification. A 229-program conformance suite and 42 worked examples are validated against the spec on every pull request. All of it is developed openly on GitHub and released under the MIT licence. + A complete compiler with 164 built-in functions, ten algebraic effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge), contract-driven testing via Z3, a language server with agent-facing proof deltas, and a 14-chapter specification. A 238-program conformance suite and 42 worked examples are validated against the spec on every pull request. All of it is developed openly on GitHub and released under the MIT licence.

diff --git a/docs/index.md b/docs/index.md index 689b1afc..7e088e31 100644 --- a/docs/index.md +++ b/docs/index.md @@ -281,7 +281,7 @@ For other models: point them at [`SKILL.md`](https://veralang.dev/SKILL.md) via ## Status -Vera is under [active development](https://raw.githubusercontent.com/aallan/vera/main/ROADMAP.md). A complete compiler with 164 built-in functions, ten algebraic effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge), contract-driven testing via [Z3](https://www.microsoft.com/en-us/research/project/z3-3/), and a 14-chapter specification. A 229-program conformance suite and 42 worked examples are validated against the spec on every pull request. All of it is developed openly on [GitHub](https://github.com/aallan/vera) and released under the MIT licence. +Vera is under [active development](https://raw.githubusercontent.com/aallan/vera/main/ROADMAP.md). A complete compiler with 164 built-in functions, ten algebraic effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge), contract-driven testing via [Z3](https://www.microsoft.com/en-us/research/project/z3-3/), and a 14-chapter specification. A 238-program conformance suite and 42 worked examples are validated against the spec on every pull request. All of it is developed openly on [GitHub](https://github.com/aallan/vera) and released under the MIT licence. ## Links diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 4c3df19a..b22363af 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -1913,15 +1913,15 @@ Imported function contracts are verified at call sites by the SMT solver. Precon Cross-module compilation uses a flattening strategy: imported function bodies are compiled into the same WASM module as the importing program. The result is a single self-contained `.wasm` binary. Imported functions are internal (not exported); only the importing program's `public` functions are WASM exports. -If two imported modules define a function, data type, or constructor with the same name, the compiler reports an error (E608/E609/E610) listing both conflicting modules. Rename one of the conflicting declarations in the source module to resolve the collision. Local definitions shadow imported names without error. +If two of a namespace's imports supply the same bare name, `vera check` refuses it — E155 (function), E156 (data type), E157 (constructor) — reported at the second supplying import, in whichever file holds the clash. For a **function** name, resolve it either by narrowing one import (`import m(other_name);`) so a single supplier remains, or by declaring the name locally and reaching the imports with the module-qualified form (`m::name(...)`). For a **data type** or **constructor** name, rename the declaration in one of the two modules: compilation refuses two modules' same-named data declarations however the importer filters or shadows them, so no import-side change resolves those. The compile-time rails E608/E609/E610 remain as the backstop and refuse a wider set — any two modules in one program, not just two suppliers of one namespace. Local definitions shadow imported names without error. A module's data type may also collide with one the prelude provides (`Option`, `Result`, `Ordering`, `UrlParts`, `Json`, `HtmlNode`, `Request`, `Response`). One name carries one layout in the compiled program, so the two contend when their shapes differ — different constructors, a different constructor order, different field types, or a different number of type parameters (their names are free, since they are matched by position). The compiler then reports **E621** at the module's declaration; rename it there, or give it the prelude's shape. A module declaring `data Json` is legal on its own, because the prelude injects `Json` only when the entry program uses it; `Option`, `Result`, `Ordering` and `UrlParts` are in every program, so a differently-shaped module declaration of one of those always contends. Declaring the type in the **entry** file instead suppresses the prelude's own, so it never contends with the prelude — but it does not settle a clash with a module that also declares the name, which is a separate pair the compiler does not yet arbitrate ([#1312](https://github.com/aallan/vera/issues/1312)). Type aliases and effect declarations are module-local and cannot be imported. If another module needs the same alias or effect, it must declare its own copy. -Module-qualified calls use `::` between the module path and the function name: `vera.math::magnitude(42)`. The dot-separated path identifies the module and `::` separates it from the function name. This syntax can be used anywhere a function call is valid, and always resolves against the specific module's public declarations — it is not affected by local shadowing. Note: module-qualified calls (`math::magnitude(42)`) are available for readability but do not yet resolve name collisions in flat compilation — the compiler will still report a collision error. A future version will support qualified-call disambiguation via name mangling. +Module-qualified calls use `::` between the module path and the function name: `vera.math::magnitude(42)`. The dot-separated path identifies the module and `::` separates it from the function name. This syntax can be used anywhere a function call is valid, and always resolves against the specific module's public declarations — it is not affected by local shadowing. Note: qualification names a call site, not an import list, so writing a clashing call as `m::name(42)` does not by itself lift E155 — the ambiguity is in the namespace. It is how you reach both suppliers once a local declaration or a narrowed import has settled which one owns the bare name. -There is no import aliasing (`import m(abs as math_abs)`) and no wildcard exclusion (`import m hiding(x)`). These are intentional design decisions, not limitations. When names clash across modules, rename the conflicting declaration in one of the source modules. This preserves the one-canonical-form principle — every function has exactly one name. +There is no import aliasing (`import m(abs as math_abs)`) and no wildcard exclusion (`import m hiding(x)`). These are intentional design decisions, not limitations. When a function name clashes across two imports, narrow one import or declare the name locally and qualify the rest; when a data type or constructor name clashes, rename it in one of the source modules. This preserves the one-canonical-form principle — every declaration has exactly one name. There are no raw strings (`r"..."`) or multi-line string literals. Use escape sequences for special characters; this is by design — alternative string syntaxes would create two representations for the same value. @@ -2236,7 +2236,7 @@ import vera.math(magnitude); vera.math::magnitude(-5) ``` -Note: if two imported modules define the same name, the compiler reports a collision error (E608/E609/E610). Rename the conflicting declaration in one of the source modules. +Note: if two of a namespace's imports supply the same bare name, `vera check` refuses it (E155 function / E156 data type / E157 constructor). Narrow one import or declare a function name locally; rename a clashing data type or constructor in one of the source modules. ### Trying to use wildcard exclusion @@ -2480,7 +2480,7 @@ Read `SKILL.md` for the full language reference. It covers syntax, slot referenc ### Conformance programs as reference -The conformance suite in `tests/conformance/` contains 229 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-three negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) instead must *fail* with the E-code in their `expected_error` field, at the stage their `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. +The conformance suite in `tests/conformance/` contains 229 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-seven negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) instead must *fail* with the E-code in their `expected_error` field, at the stage their `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. ### Workflow @@ -2670,7 +2670,7 @@ When implementing a new language feature, write the conformance program *first* ### Invariants -- All 229 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first +- All 229 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first - All 42 examples in `examples/` must pass `vera check` and `vera verify` - `mypy vera/` must be clean - `pytest tests/ -v` must pass @@ -3377,6 +3377,9 @@ Every diagnostic has a stable error code. Codes are grouped by compiler phase: - **E152**: Effect redeclares a built-in effect - **E153**: Function name is reserved - **E154**: Name is reserved for the prelude +- **E155**: Bare function name supplied by two imports +- **E156**: Bare data type name supplied by two imports +- **E157**: Bare constructor name supplied by two imports - **E160**: Array index must be Int or Nat - **E161**: Cannot index non-array type - **E170**: Let binding type mismatch diff --git a/docs/llms.txt b/docs/llms.txt index f889f329..c870199e 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -56,4 +56,4 @@ Current version: 0.1.11. The reference compiler is written in Python. Install th - [TESTING.md](https://raw.githubusercontent.com/aallan/vera/main/TESTING.md): Test suite architecture, coverage data, and test conventions. - [KNOWN_ISSUES.md](https://raw.githubusercontent.com/aallan/vera/main/KNOWN_ISSUES.md): Known bugs and limitations. - [CONTRIBUTING.md](https://raw.githubusercontent.com/aallan/vera/main/CONTRIBUTING.md): Contribution guidelines. -- [Conformance Suite](https://github.com/aallan/vera/tree/main/tests/conformance): 229 programs validating every language feature against the spec. +- [Conformance Suite](https://github.com/aallan/vera/tree/main/tests/conformance): 238 programs validating every language feature against the spec. diff --git a/spec/08-modules.md b/spec/08-modules.md index 1e97a9d0..b94bd992 100644 --- a/spec/08-modules.md +++ b/spec/08-modules.md @@ -64,7 +64,7 @@ A selective import makes only the named declarations available. Each name in the Error: Cannot import 'helper' from module 'vera.math': it is private. ``` -**Design note.** Vera does not support wildcard exclusion syntax (e.g., `import m hiding(x)`). When a module exports names that conflict with local definitions or other imports, the canonical mechanism is selective import: list exactly the names needed. Wildcard exclusion would be a semantic equivalent of selective import — the same import set expressible two ways — violating the one-canonical-form principle (§0.2.3). When wildcard import causes a name clash, the local definition shadows the import (§8.5.2), and the imported version remains accessible via module-qualified call syntax (§8.5.3). +**Design note.** Vera does not support wildcard exclusion syntax (e.g., `import m hiding(x)`). When a module exports names that conflict with local definitions or other imports, the canonical mechanism is selective import: list exactly the names needed — advice for the local-definition case (§8.5.2), and a requirement for the two-import case (§8.5.2.2). Wildcard exclusion would be a semantic equivalent of selective import — the same import set expressible two ways — violating the one-canonical-form principle (§0.2.3). When wildcard import causes a name clash, the local definition shadows the import (§8.5.2), and the imported version remains accessible via module-qualified call syntax (§8.5.3). ### 8.3.3 Grammar @@ -215,6 +215,68 @@ These are properties of the importer, not of the declaration: the same module, imported two ways, can have a declaration own the bare name in one program and be qualified-only in another. +### 8.5.2.2 Two Imports Supplying One Name + +§8.5.2 orders a local declaration against an import. Nothing orders two +**imports** against each other. When two of a namespace's imports both supply +the same bare name — each `public`, each admitted by that import's list — and +the namespace declares nothing of that name itself, the bare name names two +declarations and the language chooses neither. + +A program **MUST NOT** leave a namespace in that position. The rule covers all +three declaration namespaces, each with its own code: + +| Clashing name | Code | Compilation backstop | +|---------------|------|----------------------| +| function | **E155** | E608 | +| data type | **E156** | E609 | +| constructor | **E157** | E610 | + +A constructor is admitted by its parent type's name (§8.5.4), so +`import m(Shape)` supplies `Sq` without naming it, and two modules exporting +differently-named types that share a constructor name clash on the constructor +alone. The three are therefore reported independently. + +Each is rejected at check time, in whichever namespace holds the clash: the +entry program's, or any module's, since a module's bodies resolve in their own +namespace (§8.5.2.1) and the rule is a property of that namespace rather than of +the file being compiled. A name the built-in registry or the prelude already +owns is not a clash — the incumbent holds the bare name and the imports never +win it, exactly as a local declaration settles one (§8.5.2). + +The refusal is a property of the **import list alone**. It does not require any +body to name the clashing name, and rewriting a call in module-qualified form +does not lift it — qualification disambiguates a call site, while the clash is +in the namespace. + +For a clashing **function** name, two resolutions, differing in which +suppliers the namespace can still reach: + +- **Selective import** (§8.3.2) — list exactly the names needed, so at most one + import supplies the clashing name. The other module's declaration of it is + then outside the import list and unreachable from this namespace (§8.5.2.1). +- **A local declaration** (§8.5.2) — declare the name here. Every bare call is + then the local one, so the imports no longer compete, and each import's + declaration remains reachable through the module-qualified form (§8.5.3). + +For a clashing **data type** or **constructor** name, neither of those applies +and the resolution is to rename the declaration in one of the two modules. The +flat compilation strategy refuses two modules' same-named data declarations +whatever the importing namespace does with them (§11.16), so narrowing an import +or shadowing the name locally removes the ambiguity without making the program +compile. + +**Design note.** The alternative — defining an order, first import wins or last +— was rejected. It would make the resolved declaration implicit in import +sequence, which §0.2.2 excludes, and it would enlarge the valid-program set with +programs whose meaning depends on that sequence, which §0.2.6 excludes. It would +also make a *dependency update* a silent semantic change: a library adding an +export would rebind a downstream namespace's bare call to a different body, +where refusal reports the change at the importer. Refusal is additionally the +reversible choice — an order could still be defined later, giving every refused +program a meaning, whereas retreating from an order to refusal would break +programs that had come to rely on it. + ### 8.5.3 Module-Qualified Calls Vera supports module-qualified function calls using `::` to separate the module path from the function name: @@ -233,7 +295,7 @@ module_call: module_path "::" LOWER_IDENT "(" arg_list? ")" Module-qualified calls always resolve against the specific module's public declarations. They are not affected by local shadowing -- if the importer defines its own `magnitude`, a module-qualified call `vera.math::magnitude(x)` still calls the module's version. -**Design note.** Vera does not support import aliasing (renaming a declaration at the import site). When two imported modules export identically-named functions, the module-qualified call syntax (`vera.math::magnitude(x)`) provides unambiguous disambiguation without introducing a second name for the same declaration. Aliasing would violate the one-canonical-form principle (§0.2.3): the same function could be referenced by different names in different files, making semantically identical call sites textually distinct. +**Design note.** Vera does not support import aliasing (renaming a declaration at the import site). Where two reachable declarations share a name, the module-qualified call syntax (`vera.math::magnitude(x)`) names the one wanted without introducing a second name for the same declaration — for a name a local declaration shadows (§8.5.2), and, together with a local declaration or a selective import, for two imports supplying one name (§8.5.2.2). Aliasing would violate the one-canonical-form principle (§0.2.3): the same function could be referenced by different names in different files, making semantically identical call sites textually distinct. ### 8.5.4 Constructor Resolution @@ -245,7 +307,16 @@ import vera.collections(List); -- Nil and Cons are now available ``` -Constructor names follow the same shadowing rules as function names. +Constructor names follow the same shadowing rules as function names: a local +declaration shadows an imported constructor (§8.5.2), and a constructor name two +imports both supply is refused (§8.5.2.2, **E157**) exactly as a function name +is. An imported type's constructors are admitted by the type's name, so a +selective import naming the type admits all of them. + +Constructors differ from functions in one respect, and it is a property of +compilation rather than of resolution: two modules of one program may not +declare the same `data` name or the same constructor name at all, whatever any +namespace imports or shadows (§11.16). ## 8.6 Module Resolution Algorithm @@ -320,7 +391,7 @@ This is Pass 0 of the three-pass type-checking architecture (see Chapter 5). After module registration, the main type environment contains: - All built-in types and functions. -- All imported `public` functions (with their full signatures and contracts). +- All imported `public` functions (with their full signatures and contracts), except a bare function name two imports both supply — that name is refused (§8.5.2.2) and enters no namespace, so a bare call to it resolves to nothing rather than to whichever supplier was injected first. - All imported `public` data types (with their constructors). - All locally declared types and functions (from Pass 1). @@ -525,5 +596,6 @@ The current module system has the following limitations, each tracked as a GitHu | Limitation | Issue | Notes | |-----------|-------|-------| +| Two modules may not declare the same `data` name | [#1317](https://github.com/aallan/vera/issues/1317) | The flat namespace's collision rails (§11.16) key on the declarations rather than on what any namespace can name, so neither a selective import, a local declaration (§8.5.2), nor `private` resolves the clash — only renaming in a source module does | | No re-exports | [#127](https://github.com/aallan/vera/issues/127) | A module cannot re-export declarations imported from other modules | | No package system | [#130](https://github.com/aallan/vera/issues/130) | Module resolution is file-system-only; no package manager or registry | diff --git a/spec/11-compilation.md b/spec/11-compilation.md index 6f081513..bfbe89a7 100644 --- a/spec/11-compilation.md +++ b/spec/11-compilation.md @@ -615,7 +615,7 @@ The compilation process: Imported functions are **not** exported from the WASM module — only the importing program's `public` functions are exports. -**Name collision detection**: If two imported modules define a function (E608), data type (E609), or constructor (E610) with the same name, the compiler reports an error listing both modules. Rename the conflicting declaration in one of the source modules to resolve the collision. Qualified-call disambiguation via name mangling is planned for a future version. +**Name collision detection**: If two imported modules define a function (E608), data type (E609), or constructor (E610) with the same name, the compiler reports an error listing both modules. These rails are the backstop behind the check-phase refusal of §8.5.2.2 (E155/E156/E157), which reports the same shape earlier and in the namespace that holds it; they refuse a wider set, keyed on the declarations rather than on any namespace's imports. For a **function** name the remedies are §8.5.2.2's — a selective import, or a local declaration plus the module-qualified form. For a **data type** or **constructor** name, rename the declaration in one of the source modules: these rails consult neither visibility nor the importer's filter nor local shadowing, so no import-side change resolves them. Qualified calls do not disambiguate a collision and are not a route to doing so — §8.5.2.2 refuses the ambiguity itself, and the forward-compatible alternative it leaves open is defining a resolution order, not mangling the bare name away. An imported module's data type may collide with one the **prelude** provides in the same way, and the compiler reports **E621** at the module's declaration. The prelude's declarations are compiled into this same flat namespace, which holds one layout per name, so two declarations of a prelude name contend exactly when their *shapes* differ — different constructors, a different constructor order (the tag is the position), or different field types; type parameters are compared by position, so renaming one is not a difference. A module that restates the prelude's type shares the one layout and compiles. The diagnostic names the module and the type and offers both resolutions: rename it in the module, or give it the prelude's shape. diff --git a/tests/conformance/ch08_ambiguous_import_adt_lib_bool.vera b/tests/conformance/ch08_ambiguous_import_adt_lib_bool.vera new file mode 100644 index 00000000..b6ced22a --- /dev/null +++ b/tests/conformance/ch08_ambiguous_import_adt_lib_bool.vera @@ -0,0 +1,10 @@ +-- Conformance: the other dependency exporting that data name (Chapter 8, #1304) +-- Tests: the same type and constructor names at a DIFFERENT field type, so a +-- namespace resolving `Sq` to the wrong one is visible as a type error rather +-- than as a silently swapped layout. +module ch08_ambiguous_import_adt_lib_bool; + +public data Shape { + Sq(Bool), + Blob +} diff --git a/tests/conformance/ch08_ambiguous_import_adt_lib_int.vera b/tests/conformance/ch08_ambiguous_import_adt_lib_int.vera new file mode 100644 index 00000000..709d7502 --- /dev/null +++ b/tests/conformance/ch08_ambiguous_import_adt_lib_int.vera @@ -0,0 +1,10 @@ +-- Conformance: one of two dependencies exporting the same data name (Chapter 8, #1304) +-- Tests: a public ADT whose name and constructor a sibling module also exports. +-- On its own it is an ordinary export; the clash is a property of the namespace +-- that imports both. +module ch08_ambiguous_import_adt_lib_int; + +public data Shape { + Sq(Int), + Dot +} diff --git a/tests/conformance/ch08_ambiguous_import_adt_rejected.vera b/tests/conformance/ch08_ambiguous_import_adt_rejected.vera new file mode 100644 index 00000000..b182730f --- /dev/null +++ b/tests/conformance/ch08_ambiguous_import_adt_rejected.vera @@ -0,0 +1,18 @@ +-- Conformance: two imports supplying one bare data name are refused (Chapter 8, #1304) +-- Tests: [E156] and [E157]. Both dependencies export `Shape` and its +-- constructor `Sq`, and this program declares neither, so each bare name names +-- two declarations and the language defines no order between them. Spec 8.5.4 +-- gives constructors the same rule as functions. +import ch08_ambiguous_import_adt_lib_int; +import ch08_ambiguous_import_adt_lib_bool; + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + match Sq(3) { + Sq(@Int) -> @Int.0, + Dot -> 0 + } +} diff --git a/tests/conformance/ch08_ambiguous_import_adt_swapped_rejected.vera b/tests/conformance/ch08_ambiguous_import_adt_swapped_rejected.vera new file mode 100644 index 00000000..7674e0ce --- /dev/null +++ b/tests/conformance/ch08_ambiguous_import_adt_swapped_rejected.vera @@ -0,0 +1,17 @@ +-- Conformance: the same data refusal with the imports written the other way (Chapter 8, #1304) +-- Tests: [E156] and [E157] again. The verdict is a property of the import +-- SET, not of which import is written first: an order-sensitive rule would +-- accept one of these two spellings and reject the other. +import ch08_ambiguous_import_adt_lib_bool; +import ch08_ambiguous_import_adt_lib_int; + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + match Sq(3) { + Sq(@Int) -> @Int.0, + Dot -> 0 + } +} diff --git a/tests/conformance/ch08_ambiguous_import_lib_bool.vera b/tests/conformance/ch08_ambiguous_import_lib_bool.vera new file mode 100644 index 00000000..c4fe73fe --- /dev/null +++ b/tests/conformance/ch08_ambiguous_import_lib_bool.vera @@ -0,0 +1,13 @@ +-- Conformance: the other dependency exporting that bare name (Chapter 8, #1304) +-- Tests: the same name at a DIFFERENT return type, so a namespace resolving +-- `pick` to the wrong one is visible as a type error rather than as a silently +-- swapped body. +module ch08_ambiguous_import_lib_bool; + +public forall fn pick(@T -> @Bool) + requires(true) + ensures(@Bool.result) + effects(pure) +{ + true +} diff --git a/tests/conformance/ch08_ambiguous_import_lib_int.vera b/tests/conformance/ch08_ambiguous_import_lib_int.vera new file mode 100644 index 00000000..7eb1ca1a --- /dev/null +++ b/tests/conformance/ch08_ambiguous_import_lib_int.vera @@ -0,0 +1,13 @@ +-- Conformance: one of two dependencies exporting the same bare name (Chapter 8, #1304) +-- Tests: a public generic whose name a sibling module also exports. On its +-- own it is an ordinary export; the clash is a property of the namespace that +-- imports both, never of either library. +module ch08_ambiguous_import_lib_int; + +public forall fn pick(@T -> @Int) + requires(true) + ensures(@Int.result == 111) + effects(pure) +{ + 111 +} diff --git a/tests/conformance/ch08_ambiguous_import_qualified.vera b/tests/conformance/ch08_ambiguous_import_qualified.vera new file mode 100644 index 00000000..abc79957 --- /dev/null +++ b/tests/conformance/ch08_ambiguous_import_qualified.vera @@ -0,0 +1,22 @@ +-- Conformance: the disambiguated form of the refused program (Chapter 8, #1304) +-- Tests: a local declaration takes every bare call (8.5.2), so importing both +-- dependencies is no longer ambiguous, and each import is still reachable +-- through the module-qualified form (8.5.3). 222 + 111 == 333. +import ch08_ambiguous_import_lib_int; +import ch08_ambiguous_import_lib_bool; + +private forall fn pick(@T -> @Int) + requires(true) + ensures(@Int.result == 222) + effects(pure) +{ + 222 +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(@Int.result == 333) + effects(pure) +{ + pick(true) + ch08_ambiguous_import_lib_int::pick(true) +} diff --git a/tests/conformance/ch08_ambiguous_import_rejected.vera b/tests/conformance/ch08_ambiguous_import_rejected.vera new file mode 100644 index 00000000..22eb7df1 --- /dev/null +++ b/tests/conformance/ch08_ambiguous_import_rejected.vera @@ -0,0 +1,15 @@ +-- Conformance: two imports supplying one bare name are refused (Chapter 8, #1304) +-- Tests: [E155]. Both dependencies export `pick` and this program declares no +-- `pick` of its own, so the bare name names two declarations and the language +-- defines no order between them. Spec 8.5 refuses the name here rather than +-- ordering the imports. +import ch08_ambiguous_import_lib_int; +import ch08_ambiguous_import_lib_bool; + +public fn main(@Unit -> @Int) + requires(true) + ensures(@Int.result == 111) + effects(pure) +{ + pick(true) +} diff --git a/tests/conformance/ch08_ambiguous_import_swapped_rejected.vera b/tests/conformance/ch08_ambiguous_import_swapped_rejected.vera new file mode 100644 index 00000000..abf2dd35 --- /dev/null +++ b/tests/conformance/ch08_ambiguous_import_swapped_rejected.vera @@ -0,0 +1,14 @@ +-- Conformance: the same refusal with the imports written the other way (Chapter 8, #1304) +-- Tests: [E155] again. The verdict is a property of the import SET, not of +-- which import is written first: an order-sensitive rule would accept one of +-- these two spellings and reject the other. +import ch08_ambiguous_import_lib_bool; +import ch08_ambiguous_import_lib_int; + +public fn main(@Unit -> @Int) + requires(true) + ensures(@Int.result == 111) + effects(pure) +{ + pick(true) +} diff --git a/tests/conformance/manifest.json b/tests/conformance/manifest.json index 1c052d4d..c39bf4f9 100644 --- a/tests/conformance/manifest.json +++ b/tests/conformance/manifest.json @@ -1202,7 +1202,7 @@ "id": "ch07_state_clause_transform", "file": "ch07_state_clause_transform.vera", "chapter": 7, - "title": "State clause bodies execute \u2014 intrinsic-hybrid semantics (#976)", + "title": "State clause bodies execute — intrinsic-hybrid semantics (#976)", "level": "run", "spec_ref": "Section 7.5.2", "features": [ @@ -1261,9 +1261,9 @@ "id": "ch07_exn_string", "file": "ch07_exn_string.vera", "chapter": 7, - "title": "Exn effect handler \u2014 String payload", + "title": "Exn effect handler — String payload", "level": "run", - "spec_ref": "Section 7.4 Exn handler \u2014 String payload", + "spec_ref": "Section 7.4 Exn handler — String payload", "features": [ "exn_handler", "string_exception" @@ -1696,7 +1696,7 @@ "id": "ch08_transitive_module_import_base", "file": "ch08_transitive_module_import_base.vera", "chapter": 8, - "title": "Transitive import chain \u2014 base module", + "title": "Transitive import chain — base module", "level": "check", "spec_ref": "Section 8.6.4", "features": [ @@ -1708,7 +1708,7 @@ "id": "ch08_transitive_module_import_mid", "file": "ch08_transitive_module_import_mid.vera", "chapter": 8, - "title": "Transitive import chain \u2014 middle module", + "title": "Transitive import chain — middle module", "level": "verify", "spec_ref": "Section 8.6.4", "features": [ @@ -3293,5 +3293,125 @@ "adt", "codegen_error" ] + }, + { + "id": "ch08_ambiguous_import_lib_int", + "file": "ch08_ambiguous_import_lib_int.vera", + "chapter": 8, + "title": "Dependency exporting a name a sibling also exports", + "level": "check", + "spec_ref": "Section 8.5", + "features": [ + "cross_module_generic", + "generic_function", + "visibility" + ] + }, + { + "id": "ch08_ambiguous_import_lib_bool", + "file": "ch08_ambiguous_import_lib_bool.vera", + "chapter": 8, + "title": "The sibling dependency exporting that same name", + "level": "check", + "spec_ref": "Section 8.5", + "features": [ + "cross_module_generic", + "generic_function", + "visibility" + ] + }, + { + "id": "ch08_ambiguous_import_rejected", + "file": "ch08_ambiguous_import_rejected.vera", + "chapter": 8, + "title": "Two imports supplying one bare name are refused", + "level": "check", + "spec_ref": "Section 8.5", + "expected_error": "E155", + "features": [ + "ambiguous_import", + "bare_call", + "resolution_error" + ] + }, + { + "id": "ch08_ambiguous_import_swapped_rejected", + "file": "ch08_ambiguous_import_swapped_rejected.vera", + "chapter": 8, + "title": "The same refusal with the imports written the other way", + "level": "check", + "spec_ref": "Section 8.5", + "expected_error": "E155", + "features": [ + "ambiguous_import", + "bare_call", + "resolution_error" + ] + }, + { + "id": "ch08_ambiguous_import_qualified", + "file": "ch08_ambiguous_import_qualified.vera", + "chapter": 8, + "title": "Local declaration plus module-qualified calls disambiguate", + "level": "run", + "spec_ref": "Section 8.5.3", + "features": [ + "ambiguous_import", + "module_qualified_call", + "shadowing", + "cross_module_generic" + ] + }, + { + "id": "ch08_ambiguous_import_adt_lib_int", + "file": "ch08_ambiguous_import_adt_lib_int.vera", + "chapter": 8, + "title": "Dependency exporting a data name a sibling also exports", + "level": "check", + "spec_ref": "Section 8.5.4", + "features": [ + "adt", + "visibility" + ] + }, + { + "id": "ch08_ambiguous_import_adt_lib_bool", + "file": "ch08_ambiguous_import_adt_lib_bool.vera", + "chapter": 8, + "title": "The sibling dependency exporting that same data name", + "level": "check", + "spec_ref": "Section 8.5.4", + "features": [ + "adt", + "visibility" + ] + }, + { + "id": "ch08_ambiguous_import_adt_rejected", + "file": "ch08_ambiguous_import_adt_rejected.vera", + "chapter": 8, + "title": "Two imports supplying one bare data name are refused", + "level": "check", + "spec_ref": "Section 8.5.2.2", + "expected_error": "E156", + "features": [ + "ambiguous_import", + "adt", + "resolution_error" + ] + }, + { + "id": "ch08_ambiguous_import_adt_swapped_rejected", + "file": "ch08_ambiguous_import_adt_swapped_rejected.vera", + "chapter": 8, + "title": "The same data refusal with the imports written the other way", + "level": "check", + "spec_ref": "Section 8.5.2.2", + "expected_error": "E157", + "features": [ + "ambiguous_import", + "adt", + "resolution_error" + ] } ] diff --git a/tests/module_fixture_helpers.py b/tests/module_fixture_helpers.py index 63408843..9a2a46a9 100644 --- a/tests/module_fixture_helpers.py +++ b/tests/module_fixture_helpers.py @@ -49,6 +49,11 @@ the defect that lands at run. Both #1274's and #1299's matrices drive them, so the two issues' cells can never be built against subtly different pipelines. + +:func:`build_multi_module_past_check` (#1304) is the same pipeline for a +program the CHECKER refuses: it shares the resolve-and-check front half +(``_resolve_and_check``) and then compiles anyway, so a codegen rail that +now sits behind an earlier refusal is still driven and still asserted. """ from __future__ import annotations @@ -57,7 +62,9 @@ import wasmtime +from vera import ast from vera.checker import typecheck_with_artifacts +from vera.checker.core import CheckArtifacts from vera.codegen import compile as codegen_compile from vera.codegen import execute from vera.codegen.api import CompileResult @@ -157,6 +164,77 @@ def build_multi_module( rooted at *tmp_path*, so each module's ``direct`` flag is the production one (a transitive-only module is marked as such) rather than a hand-built fixture's default. + + Shares its resolve-and-check front half with + :func:`build_multi_module_past_check`, which keeps going where this one + raises; the two differ only in what they do about a rejected program, so + a codegen rail measured through one is measured against the same + resolution and the same artifacts as through the other. + """ + program, source, main_path, resolved, arts, check_errors = ( + _resolve_and_check(tmp_path, files, main_name) + ) + assert not check_errors, ( + f"typecheck errors: {[d for _, d in check_errors]}" + ) + vres = verify(program, source, file=str(main_path), + resolved_modules=resolved) + verify_errors = [ + (d.error_code, d.description) + for d in vres.diagnostics if d.severity == "error" + ] + result, cg_errors = _compile_resolved( + program, source, main_path, resolved, arts, + ) + return verify_errors, result, cg_errors + + +def build_multi_module_past_check( + tmp_path: Path, files: dict[str, str], + main_name: str = "main.vera", +) -> tuple[ + list[tuple[str, str]], CompileResult, list[tuple[str, str]], +]: + """Resolve + check + compile, CONTINUING past a rejected check (#1304). + + Returns ``(check_errors, compile_result, codegen_errors)``. + + :func:`build_multi_module` raises on a check error because every one of + its callers builds a check-green fixture, so an error there means the + fixture is broken. This one exists for the opposite case: a shape the + CHECKER now refuses, whose codegen rail must still be shown refusing it + too. Once #1304 moved the two-supplier refusal to the checker, no + check-green program reaches E608's ambiguity condition any more, and a + rail nothing exercises is a rail that can rot into a relaxation nobody + measures — so the shape is driven through both doors and asserted at + both, rather than at whichever one happens to answer first. + + Verification is skipped: the program is already rejected, so a verify + verdict over it would describe a program the toolchain will not build. + """ + program, source, main_path, resolved, arts, check_errors = ( + _resolve_and_check(tmp_path, files, main_name) + ) + assert check_errors, ( + "expected the checker to refuse this program; it was accepted" + ) + result, cg_errors = _compile_resolved( + program, source, main_path, resolved, arts, + ) + return check_errors, result, cg_errors + + +def _resolve_and_check( + tmp_path: Path, files: dict[str, str], main_name: str, +) -> tuple[ + ast.Program, str, Path, list[ResolvedModule], CheckArtifacts, + list[tuple[str, str]], +]: + """Write *files*, resolve *main_name*'s imports, and type-check it. + + The front half both public builders share (see their docstrings for the + difference). Returns the pieces codegen needs plus the check errors as + ``(error_code, description)`` pairs, and decides nothing about them. """ tmp_path.mkdir(parents=True, exist_ok=True) for name, src in files.items(): @@ -174,24 +252,28 @@ def build_multi_module( # `lib.vera` written as `liib.vera`, the private-wildcard cell returns # 42007 and every stage reports zero errors. # - # Raised the same way as the type-check errors below, and for the same - # reason: no caller expects one, so a resolution error means the FIXTURE - # is broken, not the compiler — and returning it would let a cell pass - # with nothing assembled. + # Raised here for BOTH builders, unlike the type-check errors: no caller + # of either expects a resolution error, so one means the FIXTURE is + # broken, not the compiler — and returning it would let a cell pass with + # nothing assembled. resolve_errors = [d.description for d in resolver.errors] assert not resolve_errors, f"module resolution errors: {resolve_errors}" diags, arts = typecheck_with_artifacts( program, source, file=str(main_path), resolved_modules=resolved, collect_module_artifacts=True, ) - check_errors = [d.description for d in diags if d.severity == "error"] - assert not check_errors, f"typecheck errors: {check_errors}" - vres = verify(program, source, file=str(main_path), - resolved_modules=resolved) - verify_errors = [ + check_errors = [ (d.error_code, d.description) - for d in vres.diagnostics if d.severity == "error" + for d in diags if d.severity == "error" ] + return program, source, main_path, resolved, arts, check_errors + + +def _compile_resolved( + program: ast.Program, source: str, main_path: Path, + resolved: list[ResolvedModule], arts: CheckArtifacts, +) -> tuple[CompileResult, list[tuple[str, str]]]: + """Compile a resolved program, returning it beside its codegen errors.""" result = codegen_compile( program, source=source, file=str(main_path), resolved_modules=resolved, expr_semantic_types=arts.expr_semantic_types, @@ -202,7 +284,7 @@ def build_multi_module( (d.error_code, d.description) for d in result.diagnostics if d.severity == "error" ] - return verify_errors, result, cg_errors + return result, cg_errors def module_value( diff --git a/tests/test_ambiguous_import_refusal_1304.py b/tests/test_ambiguous_import_refusal_1304.py new file mode 100644 index 00000000..083f5329 --- /dev/null +++ b/tests/test_ambiguous_import_refusal_1304.py @@ -0,0 +1,1109 @@ +"""#1304: two imports supplying one bare name are refused, in every namespace. + +Spec §8.5 ordered a local declaration against an import (§8.5.2) and gave the +module-qualified form for reaching what a clash hides (§8.5.3), but it defined +no order between two IMPORTS that both supply one name. Neither did the +implementation, and the gap was observable: a module importing two +dependencies that each export ``gen`` — one returning ``@Int``, one ``@Bool`` +— bound its bare call to whichever supplier a set happened to yield first, so +one unchanged file type-checked on one run and reported ``body has type Bool`` +on the next. Codegen's E608 rail caught the ENTRY-visible pair before it +mattered there; the flap lived in the shapes the rail only reached later, from +inside a module the entry program merely imports. + +The rule is now REFUSAL, and the refusal is what removes the flap: with no +pick to make, there is no iteration order to expose. That is why the +determinism cells below are the load-bearing ones — a test that only asserted +"the program is rejected" would also pass against a fix that picked +deterministically, which DESIGN.md's explicitness (§0.2.2) and +constrained-expressiveness (§0.2.6) priorities rule out. + +DEFINITION-GATED, matching the rail it generalises. The clash is refused +because the import pair exists, not because a body names it: an entry program +importing two suppliers and never calling either is E608 today, so the +check-phase refusal fires there too (:class:`TestTheRefusalIsDefinitionGated`). +Replacing a bare call with the qualified form therefore does NOT clear it — +that shape is E608 at base and E155 here, asserted rather than assumed. What +does clear it is either supplier being kept out of the bare namespace: +declaring the name locally (§8.5.2), or naming the other import's declarations +selectively. Both are exercised end to end, through to the runtime value. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +import vera +from tests.module_fixture_helpers import build_multi_module, module_value + +INT_ANSWER = 111 +LOCAL_ANSWER = 222 +OTHER_ANSWER = 5 + +# The repository this test's `vera` was imported from. Every subprocess below +# is given it as PYTHONPATH, so a checkout tested through a `PYTHONPATH` +# override measures ITSELF and not whichever copy the interpreter's default +# path happens to find — the determinism cells compare runs against each +# other, and two runs of two different trees would agree for the wrong reason. +_VERA_ROOT = Path(vera.__file__).resolve().parent.parent + +_LIB_INT = f"""\ +module libint; + +public forall fn gen(@T -> @Int) + requires(true) + ensures(@Int.result == {INT_ANSWER}) + effects(pure) +{{ {INT_ANSWER} }} +""" + +# The SAME name at a different return type. A namespace binding `gen` to the +# wrong supplier is then a type error rather than a silently swapped body, +# which is what made the pick observable in the first place: with both +# libraries returning `@Int` the flap would have been invisible. +_LIB_BOOL = f"""\ +module libbool; + +public forall fn gen(@T -> @Bool) + requires(true) + ensures(@Bool.result) + effects(pure) +{{ true }} + +public fn other(@Bool -> @Int) + requires(true) + ensures(@Int.result == {OTHER_ANSWER}) + effects(pure) +{{ {OTHER_ANSWER} }} +""" + +_MID_AB = """\ +module midc; + +import libint; +import libbool; + +public fn doorc(@Bool -> @Int) + requires(true) + ensures(true) + effects(pure) +{ gen(@Bool.0) } +""" + +# The identical module with the two imports swapped. The verdict must be a +# property of the import SET: an order-sensitive rule accepts one spelling and +# rejects the other, and a positional one (codegen's reroute map is last-wins) +# would have made these two disagree. +_MID_BA = """\ +module midc; + +import libbool; +import libint; + +public fn doorc(@Bool -> @Int) + requires(true) + ensures(true) + effects(pure) +{ gen(@Bool.0) } +""" + +_MAIN_VIA_MID = f"""\ +import midc(doorc); + +public fn main(@Unit -> @Int) + requires(true) + ensures(@Int.result == {INT_ANSWER}) + effects(pure) +{{ doorc(true) }} +""" + +_FLAP_FILES: dict[str, dict[str, str]] = { + "ab": {"libint.vera": _LIB_INT, "libbool.vera": _LIB_BOOL, + "midc.vera": _MID_AB, "main.vera": _MAIN_VIA_MID}, + "ba": {"libint.vera": _LIB_INT, "libbool.vera": _LIB_BOOL, + "midc.vera": _MID_BA, "main.vera": _MAIN_VIA_MID}, +} + + +def _write(tmp_path: Path, files: dict[str, str]) -> Path: + """Write a fixture set and return the entry program's path.""" + tmp_path.mkdir(parents=True, exist_ok=True) + for name, src in files.items(): + (tmp_path / name).write_text(src, encoding="utf-8") + return tmp_path / "main.vera" + + +def _check_json(main_path: Path, *, seed: str) -> dict: + """``vera check --json`` in a fresh interpreter under *seed*. + + A SUBPROCESS, and a fresh one per seed, because ``PYTHONHASHSEED`` is + fixed at interpreter start: the randomised string hashing this issue's + flap rode is not reachable from inside one process, so an in-process loop + would measure one seed many times and call it determinism. + """ + result = subprocess.run( + [sys.executable, "-m", "vera.cli", "check", "--json", str(main_path)], + capture_output=True, + text=True, + encoding="utf-8", + env={**os.environ, "PYTHONHASHSEED": seed, + "PYTHONPATH": str(_VERA_ROOT)}, + check=False, + ) + payload: dict = json.loads(result.stdout) + return payload + + +def _codes(payload: dict) -> list[str]: + return sorted( + d["error_code"] + for d in [*payload.get("diagnostics", ()), *payload.get("warnings", ())] + ) + + +def _error_codes(payload: dict) -> list[str]: + """Just the ERROR codes, sorted — warnings are a separate assertion.""" + return sorted(d["error_code"] for d in payload.get("diagnostics", ())) + + +def _fingerprint(payload: dict) -> str: + """Everything about a verdict a reader would notice, as one string. + + Not just the code list: a fix that refused deterministically but pointed + the diagnostic at whichever import a set yielded first would still flip + the LOCATION and the module names in the message run to run, and this + issue is about a user seeing two different answers to one question. + """ + return json.dumps( + { + "ok": payload["ok"], + "diagnostics": [ + {k: d.get(k) for k in + ("error_code", "severity", "description", "rationale", + "fix", "spec_ref", "location")} + for d in payload.get("diagnostics", ()) + ], + "warnings": [ + {k: d.get(k) for k in + ("error_code", "severity", "description", "location")} + for d in payload.get("warnings", ()) + ], + }, + sort_keys=True, + ) + + +# Enough seeds to have caught the base tree's flap with room to spare: at +# `release/v0.1.12` the same fixture answered OK on seeds 0, 2 and 3 and E121 +# on 1, 4, 5, 6 and 7, so any two of these disagree there. +_SEEDS = ("0", "1", "4", "7") + + +def test_the_subprocesses_measure_this_checkout() -> None: + """The canary for every cell below: same tree, both sides of the fork. + + Without it a PYTHONPATH mistake would have each subprocess measure an + installed copy of Vera while the in-process cells measure the working + tree, and the two halves of this file would silently be about different + compilers. + """ + result = subprocess.run( + [sys.executable, "-c", "import vera; print(vera.__file__)"], + capture_output=True, text=True, encoding="utf-8", + env={**os.environ, "PYTHONPATH": str(_VERA_ROOT)}, + check=True, + ) + assert Path(result.stdout.strip()).resolve() == Path(vera.__file__).resolve() + + +_ADT_A = """\ +module liba; + +public data Shape { + Sq(Int), + Dot +} +""" + +# Same TYPE name, same CONSTRUCTOR name, different field type — so a namespace +# binding `Shape`/`Sq` to the wrong supplier is a type error rather than a +# silently swapped layout, exactly as the function fixtures differ by return +# type. +_ADT_B = """\ +module libb; + +public data Shape { + Sq(Bool), + Blob +} +""" + +_ADT_MID = """\ +module midc; + +import liba; +import libb; + +public fn doorc(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + match Sq(3) { + Sq(@Int) -> @Int.0, + Dot -> 0 + } +} +""" + +_ADT_MID_SWAPPED = _ADT_MID.replace( + "import liba;\nimport libb;", "import libb;\nimport liba;", +) + +_ADT_MAIN = """\ +import midc(doorc); + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ doorc(()) } +""" + +_ADT_FILES: dict[str, dict[str, str]] = { + "ab": {"liba.vera": _ADT_A, "libb.vera": _ADT_B, + "midc.vera": _ADT_MID, "main.vera": _ADT_MAIN}, + "ba": {"liba.vera": _ADT_A, "libb.vera": _ADT_B, + "midc.vera": _ADT_MID_SWAPPED, "main.vera": _ADT_MAIN}, +} + + +class TestTheDataSideFlapShapes: + """The same defect in the TYPE and CONSTRUCTOR namespaces (#1304). + + Spec §8.5.4 says constructor names follow the same shadowing rules as + function names, which made the function-only refusal leave that sentence + false: two imports supplying one `data` name flapped exactly as two + supplying one `fn` name did, and the accepting seeds were the worse half + — `check` AND `verify` both passed, and the program died at `run` with an + `E609` located at line 0 of the entry file, naming two modules the entry + never imported. + + Measured at the branch point and again with the function-only fix in + place, byte-identical: accepted on hash seeds 2, 8, 9, 10 and 11 and + `[E213]` on 0, 1, 3, 4, 5, 6 and 7. + """ + + @pytest.mark.parametrize("order", ["ab", "ba"]) + def test_two_imports_supplying_one_data_name_are_refused( + self, tmp_path: Path, order: str, + ) -> None: + """Both codes, because both namespaces clash here.""" + payload = _check_json(_write(tmp_path, _ADT_FILES[order]), seed="0") + assert payload["ok"] is False + # A SUBSET assertion, deliberately: an unbound `Shape`/`Sq` leaves the + # body's slot references and match arms unresolved (E130/E313), and + # suppressing that cascade would mean the fixture no longer USES the + # ambiguous names — which is what made the flap observable. The whole + # stream, cascade included, is pinned byte-for-byte by the + # determinism cell below. + assert {"E156", "E157"} <= set(_error_codes(payload)), payload + by_code = {d["error_code"]: d for d in payload["diagnostics"]} + assert "Shape" in by_code["E156"]["description"] + assert "Sq" in by_code["E157"]["description"] + for diag in by_code.values(): + assert diag["location"]["file"].endswith("midc.vera") + + @pytest.mark.parametrize("order", ["ab", "ba"]) + def test_the_data_verdict_does_not_vary_with_the_hash_seed( + self, tmp_path: Path, order: str, + ) -> None: + """The data-side twin of the function determinism cell.""" + main_path = _write(tmp_path, _ADT_FILES[order]) + prints = {seed: _fingerprint(_check_json(main_path, seed=seed)) + for seed in _SEEDS} + assert len(set(prints.values())) == 1, ( + "the data-side verdict varies with PYTHONHASHSEED: " + + json.dumps({s: json.loads(p) for s, p in prints.items()}, + indent=2) + ) + + def test_a_shared_constructor_alone_is_E157_without_E156( + self, tmp_path: Path, + ) -> None: + """Two DIFFERENTLY-named ADTs sharing a constructor name. + + The shape that makes three codes the right split rather than one: the + type names do not clash at all, only `Sq` does, and codegen separates + the two cases as E609 and E610 for the same reason. It flapped too — + `OK` on the same seeds the type-name shape accepted on. + """ + files = { + "liba.vera": _ADT_A.replace("data Shape", "data Alpha"), + "libb.vera": _ADT_B.replace("data Shape", "data Beta"), + "midc.vera": _ADT_MID, + "main.vera": _ADT_MAIN, + } + payload = _check_json(_write(tmp_path, files), seed="0") + codes = set(_error_codes(payload)) + assert "E157" in codes and "E156" not in codes, payload + ctor = next(d for d in payload["diagnostics"] + if d["error_code"] == "E157") + assert "Sq" in ctor["description"] + + def test_renaming_is_the_remedy_the_diagnostic_offers( + self, tmp_path: Path, + ) -> None: + """And it is offered because it is the one that works. + + The function side's two remedies are deliberately absent from the + data-side fix text: E609 refuses two modules' same-named data + declarations by DECLARATION, with none of the visibility, filter or + shadowing relaxation E608 received in #1281, so neither narrowing an + import nor declaring the type locally clears it. Both were measured + against this fixture and both still died at `run` with E609; the cell + below pins that, so the fix text cannot drift into prescribing them. + """ + files = dict(_ADT_FILES["ab"]) + # BOTH names, because both namespaces clashed: renaming the type + # alone leaves `Sq` supplied twice and the program still E157. + files["libb.vera"] = ( + _ADT_B.replace("data Shape", "data Renamed").replace("Sq(", "Sqr(") + ) + assert _answer(tmp_path, files) == 3 + payload = _check_json(_write(tmp_path / "x", _ADT_FILES["ab"]), + seed="0") + fix = next(d["fix"] for d in payload["diagnostics"] + if d["error_code"] == "E156") + assert "Rename" in fix + assert "does not resolve it" in fix + + @pytest.mark.parametrize("remedy", ["selective", "local"]) + def test_the_function_remedies_do_not_clear_a_data_clash( + self, tmp_path: Path, remedy: str, + ) -> None: + """Measured, not assumed — this is why the fix texts differ. + + Each leaves the clash out of the checker's view (one supplier, or a + local declaration that owns the name), so E156/E157 correctly fall + silent; codegen still refuses the program. A future relaxation of + E609 to match #1281 would turn these cells green at `run`, which is + the signal to revisit the data-side fix text. + """ + files = dict(_ADT_FILES["ab"]) + if remedy == "selective": + files["libb.vera"] = _ADT_B + """ +public fn helper(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ 5 } +""" + files["midc.vera"] = _ADT_MID.replace( + "import libb;", "import libb(helper);", + ) + else: + files["midc.vera"] = _ADT_MID.replace( + "import libb;", + "import libb;\n\nprivate data Shape {\n Sq(Int),\n Dot\n}", + ) + assert _error_codes(_check_json(_write(tmp_path / "c", files), + seed="0")) == [] + _, _result, cg_errors = build_multi_module(tmp_path, files) + assert [c for c, _ in cg_errors if c == "E609"], cg_errors + + +class TestTheRefusedDataNamesBindToNothing: + """The data-side twin of `test_the_refused_name_binds_to_nothing`. + + Structural, because the type half has no end-to-end tell: an unresolved + type expression becomes an opaque ADT rather than a diagnostic, so a + program cannot distinguish "no `Shape` here" from "some `Shape` here" by + its verdict. The environment can, and it is the thing the injection loop + writes — so it is asserted directly, on both halves at once and against + controls, since a cell that only checked the ambiguous names would pass + just as well against an environment that registered nothing at all. + """ + + def _env(self, files: dict[str, str]) -> object: + from tests.module_fixture_helpers import fake_resolved_module + from vera.checker.core import TypeChecker + from vera.parser import parse_to_ast + + mods = [ + fake_resolved_module((name[: -len(".vera")],), src) + for name, src in files.items() if name != "main.vera" + ] + checker = TypeChecker(source=files["main.vera"], file="main.vera", + resolved_modules=mods) + checker.check_program(parse_to_ast(files["main.vera"])) + return checker.env + + def test_neither_the_type_nor_its_constructor_is_registered(self) -> None: + entry = """\ +import liba; +import libb; + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ 7 } +""" + env = self._env({"liba.vera": _ADT_A, "libb.vera": _ADT_B, + "main.vera": entry}) + assert "Shape" not in env.data_types # type: ignore[attr-defined] + assert "Sq" not in env.constructors # type: ignore[attr-defined] + # The controls: the two UNAMBIGUOUS constructors of those same two + # types are registered, so the assertions above are about ambiguity + # and not about the harvest having failed. + assert "Dot" in env.constructors # type: ignore[attr-defined] + assert "Blob" in env.constructors # type: ignore[attr-defined] + + +class TestTheDiagnosticNamesTheLastSupplyingImport: + """Position, not just presence (#1304 review). + + Every cell above would pass against a rule that reported the clash at the + FIRST supplying import, and the two spellings of one import list differ + only in which module that is — so the sequence-independence the refusal + is for would be unpinned in exactly the dimension it is about. The last + import is the one whose presence completes the clash, so that is where + the diagnostic goes. + """ + + @pytest.mark.parametrize( + ("files", "code", "last"), + [ + (_FLAP_FILES["ab"], "E155", "libbool"), + (_FLAP_FILES["ba"], "E155", "libint"), + (_ADT_FILES["ab"], "E156", "libb"), + (_ADT_FILES["ba"], "E156", "liba"), + ], + ) + def test_reported_at_the_second_import_not_the_first( + self, tmp_path: Path, files: dict[str, str], code: str, last: str, + ) -> None: + payload = _check_json(_write(tmp_path, files), seed="0") + diag = next(d for d in payload["diagnostics"] + if d["error_code"] == code) + assert diag["source_line"].strip() == f"import {last};", diag + # The mid module writes its imports on lines 3 and 4; the clash is + # completed by the second, so the line number is pinned too rather + # than left to the source-line text alone. + assert diag["location"]["line"] == 4, diag + + +class TestABuiltinOwnedNameIsNeverAmbiguous: + """The incumbent wins, so two imports of it are not a clash (#1304 review). + + Every injection in `_register_modules` is a `setdefault` onto a `TypeEnv` + the built-in registry populated first, so a dependency exporting its own + `option_map` never wins the bare name — measured as `E201` against the + PRELUDE's two-argument signature, from a program importing exactly one + such module. Two of them are therefore not ambiguous either, and the + first version of this refusal reported them anyway: a new rejection where + the branch point was green. + + Kept deliberately narrow. A prelude-owned name supplied by ONE import + beside the prelude is the existing rails' business, and this refusal is + silent there for the same reason it is silent here. + """ + + _PRELUDE_NAMED_LIB = """\ +module lib{n}; + +public fn option_map(@Int -> @Int) + requires(true) + ensures(@Int.result == 9) + effects(pure) +{ 9 } +""" + + # Only the TYPE name is shared. The constructors are per-module on + # purpose: two modules supplying one CONSTRUCTOR name is a real E157 + # whatever the type is called, and reusing one here would have made this + # cell assert the built-in carve-out while measuring that instead. + _PRELUDE_NAMED_ADT = """\ +module lib{n}; + +public data Option { + Wrapped{n}(Int) +} +""" + + def _two_libs(self, template: str) -> dict[str, str]: + return { + "liba.vera": template.replace("{n}", "a"), + "libb.vera": template.replace("{n}", "b"), + "main.vera": """\ +import liba; +import libb; + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ 7 } +""", + } + + def test_a_function_the_prelude_owns_is_not_reported( + self, tmp_path: Path, + ) -> None: + payload = _check_json( + _write(tmp_path, self._two_libs(self._PRELUDE_NAMED_LIB)), + seed="0", + ) + assert _error_codes(payload) == [] + + def test_a_data_type_the_builtins_own_is_not_reported( + self, tmp_path: Path, + ) -> None: + """`Option` is the prelude's, so two modules exporting one do not + make it ambiguous HERE — whatever the other rails make of them.""" + payload = _check_json( + _write(tmp_path, self._two_libs(self._PRELUDE_NAMED_ADT)), + seed="0", + ) + assert _error_codes(payload) == [] + + def test_the_prelude_argument_changes_the_ambiguity_half(self) -> None: + """Pins the corrected `namespace_fn_names` docstring (#1304 review). + + The old text claimed the ambiguity half was identical whether or not + the prelude names were passed, "since a prelude name is imported from + nowhere". It is not: the combinators are overridable rather than + reserved, so a dependency may export one, and the two answers differ. + Codegen's two calls pass different preludes and its E608 rail reads + the first; the checker passes its built-in snapshot and reads the + populated one. That ordering is load-bearing, so the difference is + asserted rather than described. + """ + from vera.monomorphize import namespace_fn_names + from vera.parser import parse_to_ast + + files = self._two_libs(self._PRELUDE_NAMED_LIB) + entry = parse_to_ast(files["main.vera"]) + mods = [((name[: -len(".vera")],), parse_to_ast(src)) + for name, src in files.items() if name != "main.vera"] + assert namespace_fn_names(entry, mods).ambiguous == {"option_map"} + assert namespace_fn_names( + entry, mods, prelude={"option_map"}, + ).ambiguous == frozenset() + + +class TestTheTwoViewsOfOneWalk: + """``ambiguous`` and ``ambiguous_sources`` cannot drift apart. + + The checker refuses a namespace's OWN clashes and codegen's rail reads + the union over every namespace. Both claim to be views of one walk, and + that claim is what lets the two layers be described as one rule — so it + is asserted on the structure rather than inferred from the two of them + happening to agree on the fixtures above. + """ + + def _tables(self, files: dict[str, str]) -> object: + from vera.monomorphize import namespace_fn_names + from vera.parser import parse_to_ast + + entry = parse_to_ast(files["main.vera"]) + modules = [ + ((name[: -len(".vera")],), parse_to_ast(src)) + for name, src in files.items() if name != "main.vera" + ] + return namespace_fn_names(entry, modules) + + def test_the_union_is_exactly_the_per_namespace_keys(self) -> None: + tables = self._tables(_FLAP_FILES["ab"]) + union = { + name + for clashes in tables.ambiguous_sources.values() # type: ignore[attr-defined] + for name in clashes + } + assert tables.ambiguous == frozenset(union) # type: ignore[attr-defined] + assert union == {"gen"}, union + + def test_the_clash_is_recorded_against_the_namespace_holding_it( + self, + ) -> None: + """`midc`'s, not the entry's — the distinction the refusal needed. + + The entry program imports only `midc`, so its own namespace is + clean; a per-namespace table that reported the clash against the + entry would point the diagnostic at a file that does not contain the + two imports. + """ + tables = self._tables(_FLAP_FILES["ab"]) + assert tables.ambiguous_in(None) == {} # type: ignore[attr-defined] + assert tables.ambiguous_in(("midc",)) == { # type: ignore[attr-defined] + "gen": (("libint",), ("libbool",)), + } + + def test_the_suppliers_are_listed_in_import_order(self) -> None: + """Swapping the two imports swaps the recorded order, and nothing else. + + The property the diagnostic's wording and its location both rest on. + """ + assert self._tables(_FLAP_FILES["ba"]).ambiguous_in( # type: ignore[attr-defined] + ("midc",), + ) == {"gen": (("libbool",), ("libint",))} + + +class TestTheDiagnosticIsRegisteredAtItsOwnPhase: + """E155 is a CHECK-phase code, and the registry says so. + + #1304's complaint was that a scope question was enforced by a codegen + rail — the wrong layer. Reusing E608 for the checker's refusal would + have carried that mislabelling into the fix: ``vera errors`` derives a + diagnostic's phase from its numeric range, so an E6xx code reported by + ``vera check`` tells every consumer the wrong thing about when it fires. + """ + + def test_the_code_is_registered_with_a_typecheck_phase(self) -> None: + from vera._since import SINCE + from vera.errors import ERROR_CODES + from vera.introspect import errors_payload + + assert "E155" in ERROR_CODES + assert SINCE["E155"] == "0.1.12" + items = {i["code"]: i for i in errors_payload()["items"]} # type: ignore[attr-defined,index,union-attr] + assert items["E155"]["phase"] == "typecheck" + + def test_the_refusal_reports_a_typecheck_phase_code( + self, tmp_path: Path, + ) -> None: + """Asked of the emitted diagnostic, not of the registry alone. + + The registry can be right while the emission site passes a different + code; this reads the code off ``vera check``'s own output and holds + it to the same range. + """ + from vera.introspect import errors_payload + + phases = {i["code"]: i["phase"] # type: ignore[index] + for i in errors_payload()["items"]} # type: ignore[union-attr] + main_path = _write(tmp_path, _FLAP_FILES["ab"]) + emitted = [d["error_code"] + for d in _check_json(main_path, seed="0")["diagnostics"]] + assert emitted == ["E155"] + assert phases[emitted[0]] == "typecheck" + + +class TestTheFlapShapes: + """The measured nondeterminism, now a fixed refusal.""" + + @pytest.mark.parametrize("order", ["ab", "ba"]) + def test_a_module_importing_two_suppliers_is_refused( + self, tmp_path: Path, order: str, + ) -> None: + """Either spelling of the two imports, one verdict: E155. + + The refusal is reported against the MODULE that holds the clash, and + surfaced into the program being checked — the entry program declares + nothing ambiguous itself, so a refusal scoped to the entry namespace + alone would miss this shape entirely, which is precisely the gap + codegen's rail was standing in for. + """ + main_path = _write(tmp_path, _FLAP_FILES[order]) + payload = _check_json(main_path, seed="0") + assert payload["ok"] is False + e155 = [d for d in payload["diagnostics"] + if d["error_code"] == "E155"] + assert len(e155) == 1, payload["diagnostics"] + assert "gen" in e155[0]["description"] + assert "libint" in e155[0]["description"] + assert "libbool" in e155[0]["description"] + assert e155[0]["location"]["file"].endswith("midc.vera") + + @pytest.mark.parametrize("order", ["ab", "ba"]) + def test_the_verdict_does_not_vary_with_the_hash_seed( + self, tmp_path: Path, order: str, + ) -> None: + """The cell that was impossible before the fix. + + At the branch point this fixture's verdict tracked ``PYTHONHASHSEED`` + — accepted under some, ``[E121] body has type Bool`` under others — + because the binding came from iterating a set of module paths. With + the name refused there is no binding to pick, so every seed must give + one byte-identical answer, message and location included. + """ + main_path = _write(tmp_path, _FLAP_FILES[order]) + prints = {seed: _fingerprint(_check_json(main_path, seed=seed)) + for seed in _SEEDS} + assert len(set(prints.values())) == 1, ( + "the verdict varies with PYTHONHASHSEED: " + + json.dumps({s: json.loads(p) for s, p in prints.items()}, + indent=2) + ) + assert "E155" in _codes(_check_json(main_path, seed=_SEEDS[0])) + + def test_both_import_orders_give_the_same_diagnostic_codes( + self, tmp_path: Path, + ) -> None: + """One import SET, one verdict, however it is spelled. + + Weaker than the per-order fingerprint (the two orders legitimately + name their modules in a different sequence, so their MESSAGES differ) + and aimed at the other half of the question: whether the rule reads + the import list as an ordered sequence at all. + """ + codes = { + order: _codes(_check_json( + _write(tmp_path / order, files), seed="0", + )) + for order, files in _FLAP_FILES.items() + } + assert codes["ab"] == codes["ba"] == ["E155", "E200"] + + def test_the_refused_name_binds_to_nothing( + self, tmp_path: Path, + ) -> None: + """The bare call misses (E200) rather than resolving to a supplier. + + The other half of "no pick": had the checker reported the clash and + then injected one supplier anyway, the follow-on diagnostics would + still be keyed to whichever module the injection loop reached first, + and E155 would be a label on a nondeterminism it had not removed. + Here the ambiguous name is in no namespace, so what follows a bare + call to it is the same miss under every seed. + """ + main_path = _write(tmp_path, _FLAP_FILES["ab"]) + payload = _check_json(main_path, seed="0") + misses = [d for d in payload["warnings"] + if d["error_code"] == "E200"] + assert len(misses) == 1, payload["warnings"] + assert "gen" in misses[0]["description"] + assert misses[0]["location"]["file"].endswith("midc.vera") + + +class TestTheRefusalIsDefinitionGated: + """It fires on the import PAIR, not on a use — as E608 always has.""" + + _NO_CALL_MAIN = """\ +import libint; +import libbool; + +public fn main(@Unit -> @Int) + requires(true) + ensures(@Int.result == 7) + effects(pure) +{ 7 } +""" + + _QUALIFIED_MID = f"""\ +module midc; + +import libint; +import libbool; + +public fn doorc(@Bool -> @Int) + requires(true) + ensures(@Int.result == {INT_ANSWER}) + effects(pure) +{{ libint::gen(@Bool.0) }} +""" + + def test_an_unused_ambiguous_name_is_still_refused( + self, tmp_path: Path, + ) -> None: + """No body names ``gen``; the program is refused anyway. + + This is the semantics codegen's rail already had — the same program + is E608 at the branch point, with `vera check` green — so the two + layers now answer one question the same way instead of disagreeing + about when the shape becomes illegal. + """ + main_path = _write(tmp_path, { + "libint.vera": _LIB_INT, "libbool.vera": _LIB_BOOL, + "main.vera": self._NO_CALL_MAIN, + }) + payload = _check_json(main_path, seed="0") + assert _codes(payload) == ["E155"] + + def test_swapping_a_bare_call_for_a_qualified_one_does_not_clear_it( + self, tmp_path: Path, + ) -> None: + """The qualified form alone is not the escape hatch, and never was. + + Worth pinning because the opposite is the intuitive reading of + §8.5.3's design note: qualification disambiguates a CALL, but the + clash is in the import list, and this shape is refused at the branch + point too (E608, at compile). What the refusal changes is the layer + and the message, not the verdict. The two shapes that DO clear it + are in :class:`TestTheEscapeHatches`. + """ + main_path = _write(tmp_path, { + "libint.vera": _LIB_INT, "libbool.vera": _LIB_BOOL, + "midc.vera": self._QUALIFIED_MID, "main.vera": _MAIN_VIA_MID, + }) + payload = _check_json(main_path, seed="0") + assert _codes(payload) == ["E155"] + + +class TestTheEscapeHatches: + """Two ways out, each green through to the runtime value. + + Asserted to the VALUE, not to "no diagnostics": a disambiguation that + resolved to the wrong supplier would be silent at check and wrong at run, + which is the failure mode the refusal exists to prevent. + """ + + def test_a_local_declaration_takes_every_bare_call( + self, tmp_path: Path, + ) -> None: + """§8.5.2: declare the name and both imports stay reachable. + + The bare call is the local one, and each dependency's is still + available through the module-qualified form — so this shape keeps + access to BOTH suppliers, which selective import cannot. + """ + midc = f"""\ +module midc; + +import libint; +import libbool; + +private forall fn gen(@T -> @Int) + requires(true) + ensures(@Int.result == {LOCAL_ANSWER}) + effects(pure) +{{ {LOCAL_ANSWER} }} + +public fn doorc(@Bool -> @Int) + requires(true) + ensures(@Int.result == {LOCAL_ANSWER + INT_ANSWER}) + effects(pure) +{{ gen(@Bool.0) + libint::gen(@Bool.0) }} +""" + main = f"""\ +import midc(doorc); + +public fn main(@Unit -> @Int) + requires(true) + ensures(@Int.result == {LOCAL_ANSWER + INT_ANSWER}) + effects(pure) +{{ doorc(true) }} +""" + assert _answer(tmp_path, { + "libint.vera": _LIB_INT, "libbool.vera": _LIB_BOOL, + "midc.vera": midc, "main.vera": main, + }) == LOCAL_ANSWER + INT_ANSWER + + def test_selective_import_leaves_one_supplier( + self, tmp_path: Path, + ) -> None: + """§8.5's design note: name exactly what is needed. + + ``libbool`` is imported for ``other`` alone, so only ``libint`` + supplies ``gen`` and the bare call has one meaning. + """ + midc = f"""\ +module midc; + +import libint(gen); +import libbool(other); + +public fn doorc(@Bool -> @Int) + requires(true) + ensures(@Int.result == {INT_ANSWER + OTHER_ANSWER}) + effects(pure) +{{ gen(@Bool.0) + other(@Bool.0) }} +""" + main = f"""\ +import midc(doorc); + +public fn main(@Unit -> @Int) + requires(true) + ensures(@Int.result == {INT_ANSWER + OTHER_ANSWER}) + effects(pure) +{{ doorc(true) }} +""" + assert _answer(tmp_path, { + "libint.vera": _LIB_INT, "libbool.vera": _LIB_BOOL, + "midc.vera": midc, "main.vera": main, + }) == INT_ANSWER + OTHER_ANSWER + + +class TestNonAmbiguousShapesAreUnmoved: + """The refusal keys on bare-name ambiguity and nothing wider.""" + + def test_one_import_supplying_the_name(self, tmp_path: Path) -> None: + """A single supplier is not a clash however many imports there are.""" + midc = f"""\ +module midc; + +import libint; + +public fn doorc(@Bool -> @Int) + requires(true) + ensures(@Int.result == {INT_ANSWER}) + effects(pure) +{{ gen(@Bool.0) }} +""" + assert _answer(tmp_path, { + "libint.vera": _LIB_INT, "midc.vera": midc, + "main.vera": _MAIN_VIA_MID, + }) == INT_ANSWER + + def test_two_imports_supplying_different_names( + self, tmp_path: Path, + ) -> None: + """Two wildcard imports whose exports do not overlap on the name in + question: ``gen`` comes from one, ``other`` from the other.""" + midc = f"""\ +module midc; + +import libint; +import libbool(other); + +public fn doorc(@Bool -> @Int) + requires(true) + ensures(@Int.result == {INT_ANSWER + OTHER_ANSWER}) + effects(pure) +{{ gen(@Bool.0) + other(@Bool.0) }} +""" + main = f"""\ +import midc(doorc); + +public fn main(@Unit -> @Int) + requires(true) + ensures(@Int.result == {INT_ANSWER + OTHER_ANSWER}) + effects(pure) +{{ doorc(true) }} +""" + assert _answer(tmp_path, { + "libint.vera": _LIB_INT, "libbool.vera": _LIB_BOOL, + "midc.vera": midc, "main.vera": main, + }) == INT_ANSWER + OTHER_ANSWER + + def test_a_private_namesake_is_not_a_supplier( + self, tmp_path: Path, + ) -> None: + """Only PUBLIC declarations an import list admits can clash. + + ``libpriv`` declares ``gen`` too, but privately, so it supplies + nothing to an importer — the shape #1281's relaxation is for, and one + an ambiguity test reading declarations rather than exports would + wrongly refuse. + """ + libpriv = f"""\ +module libpriv; + +private forall fn gen(@T -> @Int) + requires(true) + ensures(@Int.result == {LOCAL_ANSWER}) + effects(pure) +{{ {LOCAL_ANSWER} }} + +public fn door_priv(@Bool -> @Int) + requires(true) + ensures(@Int.result == {LOCAL_ANSWER}) + effects(pure) +{{ gen(@Bool.0) }} +""" + midc = f"""\ +module midc; + +import libint; +import libpriv; + +public fn doorc(@Bool -> @Int) + requires(true) + ensures(@Int.result == {INT_ANSWER + LOCAL_ANSWER}) + effects(pure) +{{ gen(@Bool.0) + door_priv(@Bool.0) }} +""" + main = f"""\ +import midc(doorc); + +public fn main(@Unit -> @Int) + requires(true) + ensures(@Int.result == {INT_ANSWER + LOCAL_ANSWER}) + effects(pure) +{{ doorc(true) }} +""" + assert _answer(tmp_path, { + "libint.vera": _LIB_INT, "libpriv.vera": libpriv, + "midc.vera": midc, "main.vera": main, + }) == INT_ANSWER + LOCAL_ANSWER + + def test_a_private_data_type_is_not_a_supplier( + self, tmp_path: Path, + ) -> None: + """The data twin of the private-function control. + + Only PUBLIC declarations an import list admits can clash, on this + side too: `libpriv` declares its own `Shape` privately, which + supplies nothing to an importer, so the bare name has one supplier + and no refusal is owed. + + Asserted at CHECK only, unlike its function counterpart, because + this program cannot reach a runtime value: E609 refuses two modules' + same-named data declarations without consulting visibility either, so + a private namesake is enough to stop compilation. That is the rail's + breadth rather than this refusal's, and asserting silence here is + what keeps the two from being confused. + """ + libpriv = """\ +module libpriv; + +private data Shape { + Hidden +} + +public fn door_priv(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + match Hidden { + Hidden -> 4 + } +} +""" + files = { + "liba.vera": _ADT_A, "libpriv.vera": libpriv, + "midc.vera": _ADT_MID.replace("import libb;", "import libpriv;"), + "main.vera": _ADT_MAIN, + } + assert _error_codes(_check_json(_write(tmp_path, files), + seed="0")) == [] + + def test_an_out_of_filter_namesake_is_not_a_supplier( + self, tmp_path: Path, + ) -> None: + """A public export the importer's selective list omits supplies + nothing either — the filter is part of what "supplies" means.""" + midc = f"""\ +module midc; + +import libint; +import libbool(other); + +public fn doorc(@Bool -> @Int) + requires(true) + ensures(@Int.result == {INT_ANSWER}) + effects(pure) +{{ gen(@Bool.0) }} +""" + assert _answer(tmp_path, { + "libint.vera": _LIB_INT, "libbool.vera": _LIB_BOOL, + "midc.vera": midc, "main.vera": _MAIN_VIA_MID, + }) == INT_ANSWER + + +def _answer(tmp_path: Path, files: dict[str, str]) -> object: + """Check + verify + compile + run, asserting every stage agrees.""" + verify_errors, result, cg_errors = build_multi_module(tmp_path, files) + assert not cg_errors, f"codegen errors: {cg_errors}" + assert not verify_errors, f"verify errors: {verify_errors}" + kind, payload = module_value(result) + assert kind == "ok", f"module did not load/run: {payload}" + return payload diff --git a/tests/test_module_generic_collision_1281.py b/tests/test_module_generic_collision_1281.py index 2e78646d..9bfa0257 100644 --- a/tests/test_module_generic_collision_1281.py +++ b/tests/test_module_generic_collision_1281.py @@ -16,14 +16,14 @@ * **both own the bare name** — two directly-imported, in-filter, public generics really do mangle to one ``gen$Bool``; * **some namespace can name both** — a module importing two dependencies - that each export ``gen`` resolves its own bare ``gen`` to one of them, and - neither the language nor the implementation has said which. Spec §8.5 - covers local-shadows-import and prescribes selective import for a - two-import clash; it defines no order between them. The checker defines - none either: its pick is a set-iteration artefact, and the identical - program accepts on one run and is rejected on the next — measured below. - Compiling that shape would pick a body by coin-flip, so it keeps its loud - refusal. The undecided rule is tracked as issue 1304. + that each export ``gen`` would resolve its own bare ``gen`` to one of them, + and neither the language nor the implementation had said which. Spec §8.5 + now says: the name is refused in that namespace (issue 1304), so the + CHECKER rejects the program (E155) and this rail is its backstop. Both + are asserted in the two cells below, through + :func:`~tests.module_fixture_helpers.build_multi_module_past_check`, + because a rail that no test can reach is one that can rot into a + relaxation nobody measures. Every expected value is the DECLARING module's own answer, taken from the standalone oracle — each library compiled alone with its own driver — never @@ -40,7 +40,11 @@ import pytest from tests.codegen_helpers import wat_fn_names -from tests.module_fixture_helpers import build_multi_module, module_value +from tests.module_fixture_helpers import ( + build_multi_module, + build_multi_module_past_check, + module_value, +) BASE_ANSWER = 111 MID1_ANSWER = 555 @@ -527,7 +531,14 @@ def test_two_bare_name_owners_still_collide( self, tmp_path: Path, ) -> None: """Both public, both in filter, both directly imported: both own the - entry's bare name, so both clones really do mangle to ``gen$Bool``.""" + entry's bare name, so both clones really do mangle to ``gen$Bool``. + + Refused TWICE since #1304, and both are asserted. The entry + namespace can name both suppliers, so the checker rejects the + program (E155) before codegen sees it; the rail behind that is still + driven here, because a rail nothing exercises is one that can rot + into a relaxation nobody measures. + """ main = """\ import liba(gen); import libb(gen); @@ -538,11 +549,14 @@ def test_two_bare_name_owners_still_collide( effects(pure) { gen(true) } """ - _, result, cg_errors = build_multi_module( + check_errors, result, cg_errors = build_multi_module_past_check( tmp_path, {"liba.vera": self._LIB_A, "libb.vera": self._LIB_B, "main.vera": main}, ) + assert _errors(check_errors, "E155"), ( + f"the checker let two bare-name owners through: {check_errors}" + ) assert _errors(cg_errors, "E608"), ( f"the rail let two bare-name owners through: {cg_errors}" ) @@ -552,21 +566,23 @@ def test_a_namespace_seeing_both_still_collides( ) -> None: """One module importing two dependencies that each export ``gen``. - Its own bare ``gen`` names one of them and nothing has said which. + Its own bare ``gen`` names one of them and nothing had said which. Both generics are qualified-only from the entry's point of view, so the ownership classification alone would relax this; the ambiguity gate is what keeps it loud. - The CHECKER's pick is not an order — it is a set-iteration artefact. - Over eight runs of one unchanged program the type oracle accepted - four times and reported ``body has type Bool`` four times; under a - fixed ``PYTHONHASHSEED`` it is stable, and it varies with the seed - rather than with which import is written first (the ``a, b`` and - ``b, a`` spellings give identical seed-by-seed answers). Codegen's - reroute map IS positional, last-wins. So relaxing here would compile - a body chosen by coin-flip, and would do it from a program the - checker itself rejects on half its runs. Tracked as issue 1304; - settling it there is what would let this gate go. + This is the shape #1304 was opened on and closed by. The CHECKER's + pick was not an order — it was a set-iteration artefact: over eight + runs of one unchanged program the type oracle accepted four times + and reported ``body has type Bool`` four times, stable under a fixed + ``PYTHONHASHSEED`` and varying with the seed rather than with which + import is written first. Codegen's reroute map IS positional, + last-wins. Spec §8.5 now refuses the shape outright, so there is no + pick left to be nondeterministic; the flap is pinned dead in + ``tests/test_ambiguous_import_refusal_1304.py``. + + Asserted at both layers for the reason above: the checker refuses + the program, and the rail behind it must still be refusing it. """ midc = """\ module midc; @@ -589,11 +605,14 @@ def test_a_namespace_seeing_both_still_collides( effects(pure) { doorc(true) } """ - _, result, cg_errors = build_multi_module( + check_errors, result, cg_errors = build_multi_module_past_check( tmp_path, {"liba.vera": self._LIB_A, "libb.vera": self._LIB_B, "midc.vera": midc, "main.vera": main}, ) + assert _errors(check_errors, "E155"), ( + f"an ambiguous bare name was let through: {check_errors}" + ) assert _errors(cg_errors, "E608"), ( f"an ambiguous bare name was let through: {cg_errors}" ) diff --git a/vera/README.md b/vera/README.md index bd45353a..db0eb308 100644 --- a/vera/README.md +++ b/vera/README.md @@ -84,7 +84,7 @@ execute(compile_result, ...) # → run WASM via wasmtime | `checker/` | 7,264 | Type check | Two-pass type checker (mixin package) | `typecheck()` | | ` core.py` | 1,165 | | TypeChecker class, orchestration, contracts, constraint validation | | | ` resolution.py` | 535 | | AST TypeExpr → semantic Type, inference | | -| ` modules.py` | 285 | | Cross-module registration (C7b/C7c), plus the per-module body check that makes a module's diagnostics independent of which file `vera check` was given (#1244) | | +| ` modules.py` | 476 | | Cross-module registration (C7b/C7c), plus the per-module body check that makes a module's diagnostics independent of which file `vera check` was given (#1244) and the #1304 refusal of a bare function, data-type or constructor name two imports both supply (E155/E156/E157) | | | ` registration.py` | 915 | | Pass 1 forward declarations, ability registration | | | ` expressions.py` | 1,485 | | Expression synthesis (bidirectional), operators, statements | | | ` eq_ability.py` | 199 | | Eq ability derivation checks | | @@ -748,7 +748,7 @@ Every diagnostic has a unique code grouped by compiler phase: | E5xx | Verification | `verifier.py` | | E6xx | Codegen | `codegen/` | -The `ERROR_CODES` dict in `errors.py` maps every code to a short description (157 entries — 155 `E` codes and the two `W` warning codes). Codes are stable across versions — they can be used for programmatic filtering, suppression, and documentation lookups. Formatted output shows the code in brackets: `[E130] Error at line 5, column 3:`. +The `ERROR_CODES` dict in `errors.py` maps every code to a short description (160 entries — 158 `E` codes and the two `W` warning codes). Codes are stable across versions — they can be used for programmatic filtering, suppression, and documentation lookups. Formatted output shows the code in brackets: `[E130] Error at line 5, column 3:`. ## Test Suite diff --git a/vera/_since.py b/vera/_since.py index 0f44ac5e..e31ab259 100644 --- a/vera/_since.py +++ b/vera/_since.py @@ -264,6 +264,9 @@ "E152": "0.1.9", "E153": "0.1.9", "E154": "0.1.9", + "E155": "0.1.12", + "E156": "0.1.12", + "E157": "0.1.12", "E160": "0.0.43", "E161": "0.0.43", "E170": "0.0.43", diff --git a/vera/checker/core.py b/vera/checker/core.py index 7661ffec..e54aaaa9 100644 --- a/vera/checker/core.py +++ b/vera/checker/core.py @@ -438,6 +438,18 @@ def __init__( self._resolved_module_paths: set[tuple[str, ...]] = { m.path for m in self._resolved_modules if m.direct } + # #1304: bare names two of THIS program's imports both supply, one + # set per declaration namespace. Set by + # ``_reject_ambiguous_imports``, which also reports each one (E155 + # functions / E156 data types / E157 constructors); the injection + # loops skip them, so an ambiguous name denotes nothing here rather + # than whichever supplier registered first. Initialised empty for + # the paths that register declarations without running + # ``check_program`` (the per-module harvest builds a checker and + # calls ``_register_all`` on it directly). + self._ambiguous_import_fn_names: frozenset[str] = frozenset() + self._ambiguous_import_type_names: frozenset[str] = frozenset() + self._ambiguous_import_ctor_names: frozenset[str] = frozenset() # C7b: per-module declaration registries (for ModuleCall path). self._module_functions: dict[ tuple[str, ...], dict[str, object] diff --git a/vera/checker/modules.py b/vera/checker/modules.py index ae83adbb..b71ccac3 100644 --- a/vera/checker/modules.py +++ b/vera/checker/modules.py @@ -11,6 +11,7 @@ from vera import ast from vera.environment import TypeEnv +from vera.monomorphize import namespace_adt_names, namespace_fn_names from vera.resolver import ResolvedModule @@ -22,6 +23,9 @@ def _register_modules(self, program: ast.Program) -> None: 1. Build an import-name filter from the program's ``import`` declarations (selective vs wildcard). + 1a. #1304: refuse a bare function, data-type or constructor name + two of this namespace's imports both supply, and keep it out of + the environment. 2. For each resolved module, run the registration pass in an isolated TypeChecker to populate its ``TypeEnv``, then harvest the declarations into per-module dicts. @@ -40,12 +44,21 @@ def _register_modules(self, program: ast.Program) -> None: set(imp.names) if imp.names is not None else None ) - # Snapshot builtin names (TypeEnv registers builtins in __post_init__) + # Snapshot builtin names (TypeEnv registers builtins in __post_init__). + # Hoisted above the #1304 refusal, which needs them: every injection + # below is a ``setdefault`` onto this same environment, so a name the + # built-in registry already holds is never won by an import and is + # therefore never ambiguous, however many dependencies export it. _builtins = TypeEnv() builtin_fn_names = set(_builtins.functions) builtin_data_names = set(_builtins.data_types) builtin_ctor_names = set(_builtins.constructors) + # 1a. #1304. + self._reject_ambiguous_imports( + program, builtin_fn_names, builtin_data_names, builtin_ctor_names, + ) + # 2. Register each module in isolation, harvest declarations for mod in self._resolved_modules: # Pass the module's file path so any harvested diagnostic (e.g. the @@ -183,16 +196,194 @@ def _register_modules(self, program: ast.Program) -> None: if not mod.direct: continue for fn_name, fn_info in mod_fns.items(): + # #1304: an ambiguous name is not injected AT ALL. Injecting + # one supplier and reporting the clash beside it would leave + # the follow-on diagnostics keyed to whichever module the + # injection loop reached first — the very artefact this + # refusal removes — so the name simply denotes nothing here, + # and a bare call to it misses (E200) rather than binding a + # body chosen by iteration order. Module-qualified calls are + # unaffected: they resolve against + # ``_module_functions[path]``, never this environment. + if fn_name in self._ambiguous_import_fn_names: + continue if name_filter is None or fn_name in name_filter: self.env.functions.setdefault(fn_name, fn_info) + # #1304, data side: same rule and same reason as the functions + # above. A type name two imports supply denotes nothing here, and + # so does a constructor name — which is filtered by its PARENT + # type (spec §8.5.4), so the two sets are consulted separately. for dt_name, dt_info in mod_data.items(): + if dt_name in self._ambiguous_import_type_names: + continue if name_filter is None or dt_name in name_filter: self.env.data_types.setdefault(dt_name, dt_info) for ct_name, ct_info in mod_ctors.items(): + if ct_name in self._ambiguous_import_ctor_names: + continue parent = ct_info.parent_type if name_filter is None or parent in name_filter: self.env.constructors.setdefault(ct_name, ct_info) + def _reject_ambiguous_imports( + self, program: ast.Program, + builtin_fns: set[str], builtin_types: set[str], + builtin_ctors: set[str], + ) -> None: + """Refuse a bare name two of THIS namespace's imports supply (#1304). + + Spec §8.5 orders a local declaration against an import (§8.5.2) and + prescribes the module-qualified form for reaching what a clash hides + (§8.5.3), but it defines no order between two imports that both + supply one name. Neither did the implementation: the pick was a + set-iteration artefact, and one unchanged program accepted on one run + and reported ``body has type Bool`` on the next. Refusing is what + DESIGN.md's explicitness (§0.2.2) and constrained-expressiveness + (§0.2.6) priorities give — an order would make the winning binding + implicit in import sequence, and would let a dependency ADDING an + export silently rebind a downstream bare call. + + DEFINITION-GATED, not use-gated: the clash is refused because the + import pair exists, whether or not any body names it. That is the + semantics codegen's E608 rail already has — a program importing two + suppliers and never calling either is E608 today — so the two layers + answer one question the same way, which is the whole of #1304's + complaint about three phases each deciding independently. Both read + :func:`~vera.monomorphize.namespace_fn_names`; this side asks for its + OWN namespace's clashes (to report at its own import, and to keep the + name unbound), the E608 side asks the union (to decide whether a PAIR + of modules may share the flat namespace). + + Reported once per clashing name, at the LAST import that supplies it + — the one whose presence completes the clash — and in sorted name + order, so the diagnostic stream is a function of the source alone. + + Every namespace gets its own pass: the entry program here, and each + module's through the fresh checker :meth:`_check_module_bodies` + builds for it, whose diagnostics are surfaced into this one. That is + what makes the refusal reach a module the entry program only imports + — the shape §8.5 left undefined and the only one where the flap was + observable, since E608 already refused the entry-visible pair. + + THREE NAMESPACES, three codes, mirroring the split codegen's rails + already use: functions (E155, backstopped by E608), data types (E156, + by E609) and constructors (E157, by E610). One code would have been + cheaper and wrong — a constructor clash is not a type clash (two + modules exporting differently-named ADTs can share a constructor + name, and only E610 catches that today), and the registry's existing + convention is one code per declaration namespace. + + The data side flapped exactly as the function side did — measured on + two modules each exporting a ``public data Shape`` with different + constructor field types, where ``Sq(3)`` type-checked on some hash + seeds and was ``E213`` on others. Its accepting seeds were the worse + half: ``check`` and ``verify`` both passed, and the program then died + at ``run`` with an ``E609`` located at line 0 of the entry file, + naming two modules the entry never imported. + + Unlike the function side, the data side has no in-source escape + hatch: E609/E610 refuse two modules' same-named ADTs by DECLARATION, + with no visibility, filter, or shadowing relaxation (E608 got one in + #1281; E609 did not). Measured — narrowing the second import to + exclude the type, and declaring the type locally, both leave the + program ``E609`` at compile. So these two diagnostics prescribe + renaming, which is what actually works, rather than repeating the + function side's remedies. Every program they refuse is one + E609/E610 already refused later and less precisely, so this moves a + rejection rather than adding one. + + A name the BUILT-IN registry already owns is never ambiguous: every + injection below is a ``setdefault`` onto an environment the built-ins + populated first, so the incumbent wins and the imports never compete. + The three snapshots are passed in for that reason, and not passing + the function one was an over-refusal in this method's first version — + two dependencies exporting their own ``option_map`` were reported as + a clash when a bare ``option_map`` in fact resolves to the prelude's. + """ + modules = [(mod.path, mod.program) for mod in self._resolved_modules] + fn_clashes = namespace_fn_names( + program, modules, prelude=builtin_fns, + ).ambiguous_in(None) + adt = namespace_adt_names( + program, modules, + owned_types=builtin_types, owned_ctors=builtin_ctors, + ) + type_clashes = adt.types_in(None) + ctor_clashes = adt.ctors_in(None) + self._ambiguous_import_fn_names = frozenset(fn_clashes) + self._ambiguous_import_type_names = frozenset(type_clashes) + self._ambiguous_import_ctor_names = frozenset(ctor_clashes) + + for clashes, kind, article, code in ( + (fn_clashes, "function", "a", "E155"), + (type_clashes, "data type", "a", "E156"), + (ctor_clashes, "constructor", "a", "E157"), + ): + for name, deps in clashes.items(): + labels = [".".join(dep) for dep in deps] + joined = ", ".join(f"'{label}'" for label in labels[:-1]) + joined = f"{joined} and '{labels[-1]}'" + self._error( + self._find_import_decl(program, deps[-1]), + f"Bare {kind} name '{name}' is supplied by more than one " + f"import: modules {joined}.", + rationale=( + f"Two imports supplying one bare {kind} name leave it " + "ambiguous. The language defines no order between " + f"them, so a use of '{name}' here would name " + f"{article} declaration chosen by import sequence " + "rather than by the program's text — and a dependency " + "later adding this export would silently rebind it." + ), + fix=( + self._ambiguous_fn_fix(name, labels) + if code == "E155" + else self._ambiguous_data_fix(name, kind, labels) + ), + spec_ref=( + 'Chapter 8, Section 8.5.2.2 ' + '"Two Imports Supplying One Name"' + ), + error_code=code, + ) + + @staticmethod + def _ambiguous_fn_fix(name: str, labels: list[str]) -> str: + """The two remedies that work for a clashing FUNCTION name (#1304). + + Both measured end to end, through to the runtime value: narrowing one + import leaves a single supplier, and a local declaration takes every + bare call while leaving each import reachable under ``::``. + """ + return ( + f"Import at most one supplier of '{name}': name the other " + f"import's declarations selectively, as " + f"'import {labels[-1]}();' (replace " + f"with a declaration you need from that module). To keep " + f"reaching both, declare '{name}' in this file — a local " + "declaration takes every bare call — and use the " + f"module-qualified form '{labels[0]}::{name}(...)' for the " + "imported ones." + ) + + @staticmethod + def _ambiguous_data_fix(name: str, kind: str, labels: list[str]) -> str: + """The remedy that works for a clashing TYPE or CONSTRUCTOR name. + + Renaming, and only renaming. The function side's two remedies are + deliberately NOT offered here: both were measured against this shape + and both still fail at compile with E609, because that rail refuses + two modules' same-named data declarations however the importer + filters or shadows them (spec §11.16). + """ + return ( + f"Rename the {kind} '{name}' in one of the two modules — " + f"'{labels[0]}' or '{labels[-1]}'. Narrowing an import or " + f"declaring '{name}' in this file does not resolve it: " + "compilation refuses two modules' same-named data declarations " + "whatever the importer does with them." + ) + def _check_module_bodies(self, mod: ResolvedModule) -> None: """Type-check *mod*'s bodies as *mod* itself would be checked (#1244). diff --git a/vera/codegen/core.py b/vera/codegen/core.py index 2ec43d2a..1a59bcd0 100644 --- a/vera/codegen/core.py +++ b/vera/codegen/core.py @@ -629,13 +629,14 @@ def __init__( # #1281: bare names some namespace could resolve to more than one # module's declaration — a module importing two dependencies that # each export `gen`, and declaring no `gen` of its own. Spec §8.5 - # defines no order between two imports (it prescribes naming the one - # you want), and neither does the checker: its pick is a - # set-iteration artefact that flips between runs of the identical - # program (#1304). The E608 relaxation therefore fires only for - # names OUTSIDE this set, so the shape keeps its loud refusal instead - # of compiling to whichever body that coin-flip favoured. Filled - # beside `_namespace_fn_names`, from the same walk. + # refuses that name in the namespace holding the clash rather than + # ordering the two imports (#1304), and the CHECKER reports it + # (E155), so a program reaching this pass with the name still + # ambiguous has bypassed the checker. The E608 relaxation therefore + # fires only for names OUTSIDE this set, and the shape keeps its + # refusal here too instead of compiling against whichever body the + # positional reroute map favoured. Filled beside + # `_namespace_fn_names`, from the same walk as the checker's. self._ambiguous_imported_fn_names: frozenset[str] = frozenset() # #774: imported PUBLIC generic (`forall`) FnDecls the importer must # monomorphize itself — cross-module generic monomorphization. The diff --git a/vera/codegen/modules.py b/vera/codegen/modules.py index a0575e2d..b357ce2d 100644 --- a/vera/codegen/modules.py +++ b/vera/codegen/modules.py @@ -888,27 +888,29 @@ def _generics_cannot_collide( — the collision the rail is actually for. * **no namespace can name both.** A module importing two dependencies that each export ``gen``, and declaring none itself, - resolves its own bare ``gen`` to one of them — and the language has - not said which. Spec §8.5 covers local-shadows-import and - prescribes selective import for a two-import clash; it defines no - order between the two. Nor does the implementation: the CHECKER's - pick is not an order at all but a set-iteration artefact, and the - identical program accepts on one run and reports ``body has type - Bool`` on the next (measured 4/4 over eight runs; stable under a - fixed ``PYTHONHASHSEED`` and varying with the seed, and identical - whichever textual order the two imports are written in). Codegen's - ``module_qualified_generic_targets`` loop, by contrast, IS - positional — last import wins. Compiling the shape would therefore - pick a body by coin-flip, and the two generics are qualified-only - from the entry's point of view, so the ownership classification - alone would relax it. The loud refusal stays until the rule is - decided (#1304), which is what §8.5's design note already tells the - author to do (name the import). + would resolve its own bare ``gen`` to one of them — and spec §8.5 + refuses the name outright rather than saying which (#1304). The + CHECKER is the layer that reports it (E155), because scope is a + check-phase question; this condition is the BACKSTOP behind it, + and it is deliberately the same predicate rather than a second + opinion about the same shape. It matters that it stays: the two + generics are qualified-only from the entry's point of view, so the + ownership classification alone would relax the shape, and codegen's + ``module_qualified_generic_targets`` loop IS positional (last + import wins) — so a program reaching here with the name still + ambiguous would be compiled against a body picked by import order. The ambiguity set comes from :meth:`_collect_namespace_fn_names`, the - same walk that decides which names each namespace can see for #1299 — - one derivation of one visibility rule, so the rail cannot relax - somewhere the scope says it must not. + same walk that decides which names each namespace can see for #1299 + and the one the checker's refusal reads — one derivation of one + visibility rule, so the rail cannot relax somewhere the scope says it + must not, and the two layers cannot disagree about which shape is + ambiguous. + + Reached only through a door that bypasses the checker, now that the + checker refuses the shape first: the direct-codegen collision tests + in ``tests/test_codegen_modules.py`` and, for this condition + specifically, ``build_multi_module_past_check`` in #1281's matrix. """ if not ( name in generics_by_path.get(path_a, frozenset()) diff --git a/vera/errors.py b/vera/errors.py index 04165cf9..ad7268fc 100644 --- a/vera/errors.py +++ b/vera/errors.py @@ -698,6 +698,9 @@ def diagnose_lark_error( "E152": "Effect redeclares a built-in effect", "E153": "Function name is reserved", "E154": "Name is reserved for the prelude", + "E155": "Bare function name supplied by two imports", + "E156": "Bare data type name supplied by two imports", + "E157": "Bare constructor name supplied by two imports", "E160": "Array index must be Int or Nat", "E161": "Cannot index non-array type", "E170": "Let binding type mismatch", diff --git a/vera/monomorphize.py b/vera/monomorphize.py index f1675925..a98b1dcf 100644 --- a/vera/monomorphize.py +++ b/vera/monomorphize.py @@ -770,25 +770,50 @@ class NamespaceFnNames: from the entry program contributes nothing to the entry's set (spec §8.6.4) while still holding everything its own imports allow. - ``ambiguous`` is the bare names some namespace could resolve to more than - one dependency's declaration, with no declaration of its own to settle it - — the shape spec §8.5 leaves undefined (#1304). - - One derivation because there are three consumers and they must not + ``ambiguous_sources`` maps each namespace to the bare names it could + resolve to more than one dependency's declaration, with no declaration of + its own to settle it, each paired with its supplying module paths in + IMPORT order. Spec §8.5 refuses that name in the namespace that holds + the clash (#1304), so the checker reads its own namespace's entry to + report at the offending import and to keep the name out of the type + environment. ``ambiguous`` is the union of those names over every + namespace, which is what codegen's E608 rail asks — it decides whether a + PAIR of modules may share the flat namespace at all, a question no single + namespace answers. Both come off one walk, so the layer that refuses + early and the layer that backstops it cannot disagree about which shape + is ambiguous. + + One derivation because there are four consumers and they must not disagree: codegen narrows its per-declaration ownership table with it - (``_scoped_fn_names``), codegen's E608 rail reads the ambiguity half, and - the verifier narrows discovery with it. Two walks over the same imports - could differ about a filter or a visibility, and the two sides of the - #732 differential would then discover different clones. + (``_scoped_fn_names``), codegen's E608 rail reads the ambiguity union, + the CHECKER refuses its own namespace's clashes (#1304), and the verifier + narrows discovery with it. Two walks over the same imports could differ + about a filter or a visibility, and the two sides of the #732 + differential would then discover different clones. """ by_namespace: Mapping[tuple[str, ...] | None, frozenset[str]] ambiguous: frozenset[str] + ambiguous_sources: Mapping[ + tuple[str, ...] | None, + Mapping[str, tuple[tuple[str, ...], ...]], + ] = field(default_factory=dict) def visible(self, path: tuple[str, ...] | None) -> frozenset[str]: """The names *path*'s namespace can NAME; empty for an unknown path.""" return self.by_namespace.get(path, frozenset()) + def ambiguous_in( + self, path: tuple[str, ...] | None, + ) -> Mapping[str, tuple[tuple[str, ...], ...]]: + """*path*'s OWN clashing bare names, each to its suppliers (#1304). + + Import order, so a diagnostic can name the import that introduced the + clash rather than whichever module a set happened to yield first — + the same nondeterminism this refusal exists to remove. + """ + return self.ambiguous_sources.get(path, {}) + def namespace_fn_names( entry: ast.Program, @@ -813,9 +838,26 @@ def namespace_fn_names( them makes the result independent of WHEN it is called, which is the property the two sides need and the one ``test_discovery_scopes_agree_between_the_two_sides`` checks. They also - join the "declared here" set for the ambiguity test, so a program whose - entry declarations already contain them (the verifier's) and one whose do - not (codegen's) still answer identically. + join the "declared here" set for the ambiguity test: a name the prelude + or the built-in registry already owns is never ambiguous however many + dependencies export it, because the importer's injection is a + ``setdefault`` and the incumbent wins — measured, with a module exporting + its own one-argument ``option_map``, as ``E201`` against the PRELUDE's + two-argument signature. + + That makes the two halves of the result behave differently under this + argument, and the earlier claim that the ambiguity half is "identical + either way" was wrong. A dependency MAY export a prelude-named + declaration — the combinators are overridable, not reserved + (:func:`vera.prelude.overridable_builtin_names`) — so two dependencies + exporting ``option_map`` are ambiguous under ``prelude=()`` and are not + under the populated set. Codegen calls + ``_collect_namespace_fn_names`` twice, before and after its prelude pass, + and its E608 rail reads the FIRST (prelude-empty) answer because + ``_register_modules`` runs between them; the checker passes its built-in + snapshot and so reads the populated one. The ordering is therefore + load-bearing rather than incidental, and is pinned by + ``test_the_prelude_argument_changes_the_ambiguity_half``. """ public_fns: dict[tuple[str, ...], frozenset[str]] = {} module_list = list(modules) @@ -827,40 +869,195 @@ def namespace_fn_names( and (tld.visibility or "private") == "public" ) - ambiguous: set[str] = set() - - def visible(prog: ast.Program) -> frozenset[str]: + def visible( + prog: ast.Program, + ) -> tuple[frozenset[str], dict[str, tuple[tuple[str, ...], ...]]]: own = { tld.decl.name for tld in prog.declarations if isinstance(tld.decl, ast.FnDecl) } | prelude_names names = set(own) - # Which dependency each importable name came from. A name this - # namespace declares ITSELF is never ambiguous however many + # Which dependency each importable name came from, in IMPORT order. A + # name this namespace declares ITSELF is never ambiguous however many # dependencies also export it — the local declaration owns every bare # call here (spec §8.5.2). - sources: dict[str, set[tuple[str, ...]]] = {} + # + # ``sorted`` over the exports, not because this loop's order changes + # the ANSWER — each name's supplier list follows the enclosing import + # loop either way — but because a set of strings iterates in an order + # that varies with the interpreter's hash seed, and #1304 is a defect + # that reached the user's diagnostics through exactly that. Nothing + # downstream of a namespace table should be able to notice a run. + sources: dict[str, list[tuple[str, ...]]] = {} for imp in prog.imports: dep = tuple(imp.path) exported = public_fns.get(dep) if exported is None: continue - for name in exported: + for name in sorted(exported): if imp.names is None or name in imp.names: names.add(name) - sources.setdefault(name, set()).add(dep) - ambiguous.update( - name for name, deps in sources.items() + deps = sources.setdefault(name, []) + if dep not in deps: + deps.append(dep) + clashes = { + name: tuple(deps) + for name, deps in sorted(sources.items()) if len(deps) > 1 and name not in own - ) - return frozenset(names) + } + return frozenset(names), clashes + + by_namespace: dict[tuple[str, ...] | None, frozenset[str]] = {} + ambiguous_sources: dict[ + tuple[str, ...] | None, Mapping[str, tuple[tuple[str, ...], ...]], + ] = {} + for key, prog in [(None, entry), *module_list]: + by_namespace[key], ambiguous_sources[key] = visible(prog) + return NamespaceFnNames( + by_namespace, + frozenset( + name for clashes in ambiguous_sources.values() for name in clashes + ), + ambiguous_sources, + ) - by_namespace: dict[tuple[str, ...] | None, frozenset[str]] = { - None: visible(entry), - } + +@dataclass(frozen=True) +class NamespaceAdtNames: + """Which bare TYPE and CONSTRUCTOR names two imports both supply (#1304). + + The data-side twin of :class:`NamespaceFnNames`'s ambiguity half, and it + has to be a second table rather than two more fields on that one because + the three namespaces are filtered differently: a selective import names + FUNCTIONS and TYPES directly, while a constructor is admitted by its + PARENT type's name (spec §8.5.4), so ``import m(Shape)`` supplies ``Sq`` + without ever mentioning it. + + Both maps are per namespace — ``None`` for the entry program — from the + clashing bare name to the module paths supplying it, in IMPORT order. + The two are tracked independently because they come apart: two modules + exporting differently-named ADTs that happen to share a constructor name + clash on the constructor alone, which is the shape codegen separates as + E610 from E609. + + Only the CHECKER reads this. Codegen's E609/E610 rails ask a different + question — whether two modules' declarations can share the flat + namespace at all — and answer it from declarations rather than from any + namespace's imports, so they refuse a superset and stay as they are. + """ + + ambiguous_types: Mapping[ + tuple[str, ...] | None, Mapping[str, tuple[tuple[str, ...], ...]], + ] + ambiguous_ctors: Mapping[ + tuple[str, ...] | None, Mapping[str, tuple[tuple[str, ...], ...]], + ] + + def types_in( + self, path: tuple[str, ...] | None, + ) -> Mapping[str, tuple[tuple[str, ...], ...]]: + """*path*'s clashing bare TYPE names, each to its suppliers.""" + return self.ambiguous_types.get(path, {}) + + def ctors_in( + self, path: tuple[str, ...] | None, + ) -> Mapping[str, tuple[tuple[str, ...], ...]]: + """*path*'s clashing bare CONSTRUCTOR names, each to its suppliers.""" + return self.ambiguous_ctors.get(path, {}) + + +def namespace_adt_names( + entry: ast.Program, + modules: Iterable[tuple[tuple[str, ...], ast.Program]], + owned_types: Iterable[str] = (), + owned_ctors: Iterable[str] = (), +) -> NamespaceAdtNames: + """Build the per-namespace data-side clash tables (#1304). + + *owned_types* / *owned_ctors* are the names something OTHER than this + program's declarations already owns in every namespace — the built-in and + prelude ADTs (``Option``, ``Result``, ``Ordering``, ``UrlParts``) and + their constructors. They join the "declared here" set rather than the + supplied one, so a namespace whose two imports both export a ``data + Option`` is NOT reported here: the built-in registry occupies that bare + name and the imports never win it, exactly as a local declaration would + settle the clash (spec §8.5.2). The checker passes its own built-in + snapshot, so this table cannot disagree with the environment the + injection loop actually builds — that loop is a ``setdefault`` over a + ``TypeEnv`` the built-ins already populated. + """ + public_adts: dict[tuple[str, ...], dict[str, frozenset[str]]] = {} + module_list = list(modules) + base_types = frozenset(owned_types) + base_ctors = frozenset(owned_ctors) for path, prog in module_list: - by_namespace[path] = visible(prog) - return NamespaceFnNames(by_namespace, frozenset(ambiguous)) + public_adts[path] = { + tld.decl.name: frozenset( + ctor.name for ctor in tld.decl.constructors + ) + for tld in prog.declarations + if isinstance(tld.decl, ast.DataDecl) + and (tld.visibility or "private") == "public" + } + + def clashes( + prog: ast.Program, + ) -> tuple[ + dict[str, tuple[tuple[str, ...], ...]], + dict[str, tuple[tuple[str, ...], ...]], + ]: + own_types = { + tld.decl.name for tld in prog.declarations + if isinstance(tld.decl, ast.DataDecl) + } | base_types + own_ctors = { + ctor.name for tld in prog.declarations + if isinstance(tld.decl, ast.DataDecl) + for ctor in tld.decl.constructors + } | base_ctors + type_sources: dict[str, list[tuple[str, ...]]] = {} + ctor_sources: dict[str, list[tuple[str, ...]]] = {} + for imp in prog.imports: + dep = tuple(imp.path) + exported = public_adts.get(dep) + if exported is None: + continue + # ``sorted`` for the same reason as the function twin: a set of + # strings iterates in hash-seed order, and #1304 is a defect that + # reached the user's diagnostics through exactly that. + for adt_name in sorted(exported): + if imp.names is not None and adt_name not in imp.names: + continue + for bucket, names in ( + (type_sources, (adt_name,)), + (ctor_sources, sorted(exported[adt_name])), + ): + for name in names: + deps = bucket.setdefault(name, []) + if dep not in deps: + deps.append(dep) + return ( + { + name: tuple(deps) + for name, deps in sorted(type_sources.items()) + if len(deps) > 1 and name not in own_types + }, + { + name: tuple(deps) + for name, deps in sorted(ctor_sources.items()) + if len(deps) > 1 and name not in own_ctors + }, + ) + + types: dict[ + tuple[str, ...] | None, Mapping[str, tuple[tuple[str, ...], ...]], + ] = {} + ctors: dict[ + tuple[str, ...] | None, Mapping[str, tuple[tuple[str, ...], ...]], + ] = {} + for key, prog in [(None, entry), *module_list]: + types[key], ctors[key] = clashes(prog) + return NamespaceAdtNames(types, ctors) def public_generic_names(module_program: ast.Program) -> set[str]: From c7c0ba3b251b9179d420124cf0061bda0a0f680e Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 15:14:18 +0100 Subject: [PATCH 38/69] Close CodeRabbit wave 1: six findings, two of them measured wrong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **E200's severity, measured rather than relabelled.** The CHANGELOG called the cascade "W-severity", conflating two things the repo keeps apart: `E200` is an E-coded diagnostic emitted at `severity="warning"`, which the `--json` envelope reports in `warnings` rather than `diagnostics` and which does not fail the check on its own — a program whose only diagnostic is `E200` reports `ok: true` and exits 0. The W-series is the separate `W001`/`W002` code namespace and this is not one of them. The bullet now says exactly that. **`E610`'s evidence, gathered on its own axis.** The #1317 row demonstrated only the `E609` half while covering both codes. Measured: `liba` exporting `public data Alpha { Sq(Int), … }` beside `libb` exporting `public data Beta { Sq(Bool), … }` — two DIFFERENT types sharing only the constructor `Sq` — is `[E157]` at check and `[E610]` at compile, and the same three remedies fail it the same way (narrow the second import, declare a local `data Own { Sq(Int), … }`, make one supplier `private`: each check-green and verify-green, each still `[E610]` at `vera compile`). The row now carries that beside the `E609` evidence and scopes each claim to its code, and two new cells pin it — the `E610` backstop for the constructor-only shape, and `private` as a third parametrised non-escape beside the two the file already had. **`E608`'s predicate, stated as the code implements it.** Spec §11.16 and SKILL.md both said the rails refuse "any two modules in one program", which is true of `E609`/`E610` and false of `E608` since #1281: `_generics_cannot_collide` exempts a pair of top-level generics the ownership classification proves land in distinct clone namespaces, because those emit nothing under the bare name. Both now give the declaration-based wording to `E609`/`E610` only and state `E608`'s own condition — same-named function declarations of any visibility, the ones that would share the flattened `$name`, minus that exception. **§8.7.2's environment bullets cover all three namespaces.** They excluded ambiguous function names while the next bullet still promised every imported public data type and constructor entered the environment, which `E156`/`E157` made false and the M16 structural cell disproves. The exclusion is now stated once over all three, including the two ways the data side comes apart: a type excluded for a clash takes its constructors with it, and a constructor two differently-named types supply is excluded alone while both types remain. **TESTING.md's conformance figures reconciled against the manifest oracle.** The parametrized-suite line said 1,130 where the file collects 1,175, and the dual-target sentence said 166 run-level programs of which 116 are dual-tested where the manifest now holds 167 and `TestDualTargetConformance` reports 117 passed beside 50 skipped. The skip breakdown is unchanged and re-measured rather than assumed — 43 host-family, 6 with no public zero-argument `main`, 1 nondeterministic — since the one run-level program this branch adds is dual-tested. The #1281 row's "all 226 conformance programs" described a revert experiment run at that corpus size; the figure is dropped rather than restated as 235, which would claim a re-measurement not taken. **A crashed CLI no longer reports as an empty-document parse error.** `_check_json` parsed stdout with `check=False`, so a seed-specific crash — the exact failure the determinism cells exist to catch — surfaced as a `JSONDecodeError` about column 1 with the exit code and the whole stderr discarded. The parse is split out and raises with the seed, the exit code and both streams attached; a cell drives the helper at a command that exits 3 with an empty stdout and a marker on stderr and asserts all three reach the message. Reverting the handler to the bare `json.loads` fails that cell with the original `JSONDecodeError`, which is what makes it load-bearing rather than decorative. Co-Authored-By: Claude --- AGENTS.md | 8 +- CHANGELOG.md | 2 +- CLAUDE.md | 8 +- FAQ.md | 4 +- README.md | 2 +- ROADMAP.md | 2 +- SKILL.md | 4 +- TESTING.md | 34 +++--- docs/SKILL.md | 4 +- docs/llms-full.txt | 16 +-- spec/08-modules.md | 6 +- spec/11-compilation.md | 2 +- tests/test_ambiguous_import_refusal_1304.py | 112 +++++++++++++++++--- vera/README.md | 2 +- 14 files changed, 148 insertions(+), 58 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5fc4929c..ddb69c31 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Read `SKILL.md` for the full language reference. It covers syntax, slot referenc ### Conformance programs as reference -The conformance suite in `tests/conformance/` contains 229 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-seven negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) instead must *fail* with the E-code in their `expected_error` field, at the stage their `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. +The conformance suite in `tests/conformance/` contains 238 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-seven negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) instead must *fail* with the E-code in their `expected_error` field, at the stage their `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. ### Workflow @@ -187,9 +187,9 @@ Each stage is a module with a single public API function (`parse_file`, `transfo pytest tests/ -v # Run all tests (see TESTING.md) pytest tests/test_conformance.py -v # Conformance suite only mypy vera/ # Type-check the compiler -python scripts/check_conformance.py # All 229 conformance programs hold (positives pass; negatives fail with their E-code) +python scripts/check_conformance.py # All 238 conformance programs hold (positives pass; negatives fail with their E-code) python scripts/check_examples.py # All 42 examples must pass -python scripts/check_corpus_canonical.py # All 278 corpus programs in canonical form +python scripts/check_corpus_canonical.py # All 287 corpus programs in canonical form ``` Test helpers follow a pattern: `_check_ok(source)` / `_check_err(source, match)` / `_verify_ok(source)` / `_verify_err(source, match)`. See existing tests for examples. @@ -198,7 +198,7 @@ When implementing a new language feature, write the conformance program *first* ### Invariants -- All 229 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first +- All 238 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first - All 42 examples in `examples/` must pass `vera check` and `vera verify` - `mypy vera/` must be clean - `pytest tests/ -v` must pass diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eea9565..e34b5c82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed -- **Two imports supplying one bare name are refused, in every namespace** ([#1304](https://github.com/aallan/vera/issues/1304)). Spec §8.5 ordered a local declaration against an import (§8.5.2) and gave the module-qualified form for reaching what a clash hides (§8.5.3), but defined no order between two *imports* that both supply one name. Neither did the implementation, and the gap was reachable: a module importing two dependencies that each export `forall fn gen` — one returning `@Int`, one `@Bool` — bound its bare call to whichever supplier a set of module paths happened to yield first, so one unchanged file was `vera check`-green on one run and `[E121] body has type Bool` on the next. Measured at the branch point across eight consecutive runs and eight hash seeds: accepted on seeds 0, 2 and 3, rejected on 1, 4, 5, 6 and 7, with the winner tracking module-name hash order rather than which import is written first. Codegen's E608 rail caught the *entry-visible* pair before it could matter there; the flap lived in the shapes the rail only reached at compile, from inside a module the entry program merely imports. Spec §8.5.2.2 now states the rule — a program **MUST NOT** leave a namespace with two imports supplying one bare function name — and the checker enforces it as **E155**, a check-phase code for a scope question that had been enforced by a codegen rail at the wrong layer. Refusing is what removes the flap rather than merely labelling it: with no pick to make, there is no iteration order left to expose, which a deterministic first-wins order would not have achieved (it would make the resolved declaration implicit in import sequence, §0.2.2, and let a library *adding* an export silently rebind a downstream bare call). The refusal is **definition-gated**, matching the rail it generalises: it fires because the import pair exists, not because a body names it, so an entry program importing two suppliers and never calling either is refused exactly as E608 already refused it, and rewriting a bare call in module-qualified form does not lift it. Two shapes clear it, both exercised through to their runtime value: a **local declaration** of the name (§8.5.2 — every bare call is then the local one, and each import stays reachable through `dep::name(...)`), or a **selective import** narrowing the other module's list. The ambiguity predicate is the one `namespace_fn_names` already derived for #1281 and #1299, now exposed per namespace as well as unioned, so the layer that refuses early and the layer that backstops it cannot disagree about which shape is ambiguous; the E608 condition keeps its cell, driven through a door that bypasses the checker. An ambiguous name is not injected into the type environment at all — reporting the clash while binding one supplier would leave the follow-on diagnostics keyed to whichever module the injection loop reached first, which is the nondeterminism the refusal exists to remove — so a bare call to it misses with the ordinary `W`-severity `E200` instead. **The data namespaces flapped the same way and are folded in.** Spec §8.5.4 gives constructor names the same shadowing rules as function names, which a function-only refusal would have made false: two modules each exporting a `public data Shape` with different constructor field types type-checked on some hash seeds and reported `[E213]` on others (accepted on seeds 2, 8, 9, 10 and 11; rejected on 0, 1, 3, 4, 5, 6 and 7), and the accepting seeds were the worse half — `check` **and** `verify` both passed, and the program died at `run` with an `E609` located at line 0 of the entry file, naming two modules the entry never imported. Data types are now **E156** and constructors **E157**, one code per declaration namespace exactly as codegen splits E608/E609/E610, and reported independently because they come apart: two modules exporting differently-named types that share a constructor name clash on the constructor alone. Their remedy differs from the function one and says so — E609/E610 refuse two modules' same-named data declarations by DECLARATION, consulting neither visibility nor the importer's filter nor local shadowing (the relaxation E608 received in [#1281](https://github.com/aallan/vera/issues/1281) has no data-side twin), so narrowing an import or shadowing the name locally leaves the program `E609` at compile. Both were measured against the fixture and both fail — as does marking one declaration `private` — so the two diagnostics prescribe renaming, and a cell pins that measurement so the fix text cannot drift into offering remedies that do not work. That rail over-breadth is now tracked as [#1317](https://github.com/aallan/vera/issues/1317). **A name the built-in registry already owns is not a clash** — the injection loops are `setdefault` over a `TypeEnv` the built-ins populate first, so a dependency exporting its own `option_map` never wins the bare name (measured as `E201` against the *prelude's* two-argument signature). The first cut of this refusal did not pass the built-in snapshot and reported two such dependencies as a clash, which was a new rejection rather than an earlier one; `namespace_fn_names`' claim that its ambiguity half is identical with or without the prelude argument was wrong for the same reason and is corrected, with the codegen call ordering it depends on now pinned by a cell. +- **Two imports supplying one bare name are refused, in every namespace** ([#1304](https://github.com/aallan/vera/issues/1304)). Spec §8.5 ordered a local declaration against an import (§8.5.2) and gave the module-qualified form for reaching what a clash hides (§8.5.3), but defined no order between two *imports* that both supply one name. Neither did the implementation, and the gap was reachable: a module importing two dependencies that each export `forall fn gen` — one returning `@Int`, one `@Bool` — bound its bare call to whichever supplier a set of module paths happened to yield first, so one unchanged file was `vera check`-green on one run and `[E121] body has type Bool` on the next. Measured at the branch point across eight consecutive runs and eight hash seeds: accepted on seeds 0, 2 and 3, rejected on 1, 4, 5, 6 and 7, with the winner tracking module-name hash order rather than which import is written first. Codegen's E608 rail caught the *entry-visible* pair before it could matter there; the flap lived in the shapes the rail only reached at compile, from inside a module the entry program merely imports. Spec §8.5.2.2 now states the rule — a program **MUST NOT** leave a namespace with two imports supplying one bare function name — and the checker enforces it as **E155**, a check-phase code for a scope question that had been enforced by a codegen rail at the wrong layer. Refusing is what removes the flap rather than merely labelling it: with no pick to make, there is no iteration order left to expose, which a deterministic first-wins order would not have achieved (it would make the resolved declaration implicit in import sequence, §0.2.2, and let a library *adding* an export silently rebind a downstream bare call). The refusal is **definition-gated**, matching the rail it generalises: it fires because the import pair exists, not because a body names it, so an entry program importing two suppliers and never calling either is refused exactly as E608 already refused it, and rewriting a bare call in module-qualified form does not lift it. Two shapes clear it, both exercised through to their runtime value: a **local declaration** of the name (§8.5.2 — every bare call is then the local one, and each import stays reachable through `dep::name(...)`), or a **selective import** narrowing the other module's list. The ambiguity predicate is the one `namespace_fn_names` already derived for #1281 and #1299, now exposed per namespace as well as unioned, so the layer that refuses early and the layer that backstops it cannot disagree about which shape is ambiguous; the E608 condition keeps its cell, driven through a door that bypasses the checker. An ambiguous name is not injected into the type environment at all — reporting the clash while binding one supplier would leave the follow-on diagnostics keyed to whichever module the injection loop reached first, which is the nondeterminism the refusal exists to remove — so a bare call to it misses with an ordinary `E200` instead — an E-coded diagnostic emitted at **warning** severity, which the `--json` envelope reports in `warnings` rather than `diagnostics` and which does not fail the check on its own (measured: a program whose only diagnostic is `E200` reports `ok: true` and exits 0). The W-series is the separate `W001`/`W002` code namespace, and this is not one of them. **The data namespaces flapped the same way and are folded in.** Spec §8.5.4 gives constructor names the same shadowing rules as function names, which a function-only refusal would have made false: two modules each exporting a `public data Shape` with different constructor field types type-checked on some hash seeds and reported `[E213]` on others (accepted on seeds 2, 8, 9, 10 and 11; rejected on 0, 1, 3, 4, 5, 6 and 7), and the accepting seeds were the worse half — `check` **and** `verify` both passed, and the program died at `run` with an `E609` located at line 0 of the entry file, naming two modules the entry never imported. Data types are now **E156** and constructors **E157**, one code per declaration namespace exactly as codegen splits E608/E609/E610, and reported independently because they come apart: two modules exporting differently-named types that share a constructor name clash on the constructor alone. Their remedy differs from the function one and says so — E609/E610 refuse two modules' same-named data declarations by DECLARATION, consulting neither visibility nor the importer's filter nor local shadowing (the relaxation E608 received in [#1281](https://github.com/aallan/vera/issues/1281) has no data-side twin), so narrowing an import or shadowing the name locally leaves the program `E609` at compile. Both were measured against the fixture and both fail — as does marking one declaration `private` — so the two diagnostics prescribe renaming, and a cell pins that measurement so the fix text cannot drift into offering remedies that do not work. That rail over-breadth is now tracked as [#1317](https://github.com/aallan/vera/issues/1317). **A name the built-in registry already owns is not a clash** — the injection loops are `setdefault` over a `TypeEnv` the built-ins populate first, so a dependency exporting its own `option_map` never wins the bare name (measured as `E201` against the *prelude's* two-argument signature). The first cut of this refusal did not pass the built-in snapshot and reported two such dependencies as a clash, which was a new rejection rather than an earlier one; `namespace_fn_names`' claim that its ambiguity half is identical with or without the prelude argument was wrong for the same reason and is corrected, with the codegen call ordering it depends on now pinned by a cell. - **A user-defined `fn get` / `fn put` is no longer hijacked by an enclosing handler** ([#1284](https://github.com/aallan/vera/issues/1284)). Three sites answered "does this `get` mean the user's declaration or the effect operation?" independently. The checker answers user-fn-first — `_check_call_with_args` looks a bare name up as a function before it looks it up as an operation, so a declaration named `get` owns every bare `get(...)` in its scope, which an arity or argument-type error at such a call site proves by reporting the *user's* signature (E201/E202). Codegen answered twice more: the declared-effect row in `vera/codegen/functions.py` withheld the intrinsic when `_fn_sigs` already owned the name, and the handler expression in `vera/wasm/calls_handlers.py` installed `get`/`put` unconditionally. From `vera check`-green source that produced, depending on the nesting shape, a **silently wrong value** (`nat_to_int(get(3))` under `handle[State](@Int = 5)` returned the cell's 5 for the function's 4, and the argument was not even emitted), a **module WASM validation rejects** (a `@Bool`-returning user `get` took `state_get_Int`'s i64 into an `i32` position; different-family nesting took the *enclosing* cell's getter at the wrong width), or a **spurious `[E602]`** in which the #1233 unaddressable-cell gate refused `main` outright, naming "a bare or qualified State operation `get`" the program never contained. The repair is one predicate, `vera.slots.bare_call_denotes_user_fn`, stating the checker's rule once and consumed by the bare-call dispatch in `vera/wasm/calls.py` (which now gates the clause-inline registry, the host-cell intrinsics and the addressability gate together), by the three bare-`FnCall` result-type inference sites, and by the monomorphizer's discovery walk — each passing its own name table, so the sites cannot answer differently about the table they share. Gating the *dispatch* rather than the *registries* is what makes it correct rather than merely consistent: the registries record which cell an op name reaches, which is true whatever the program's declarations are called, and withholding an entry answered both questions with one table. That is why the gate-only fix measured during PR #1283's review turned the loud skip into a differently-broken module, and why it also cost the qualified spelling its cell — `State.put(5)` in a function that also declares `fn put` compiled to `call $vera.put` and failed to link, which now lowers to the intrinsic the checker always meant. Discovery's `MonoContext.fn_names` moves to the same lookup-time question, so a `get(())` fixing a generic's type variable under a handler names the clone the rewrite emits. All 256 conformance and example programs emit byte-identical WAT. **The `W002` async-commutativity warning was the same defect in the checker's own file** and is corrected with them: `_collect_expr_effects` asked `lookup_effect_op` before the scoped function lookup — the last op-first consumer — so a user function named after an operation contributed the *operation's* parent effect to the commutativity analysis instead of its own declared row, wrong in both directions. A **pure** `fn get`, in a program containing no `State` at all, drew `async argument performs State effects`; a `fn get` that performs `IO`, under a row naming `Http` first, drew **no warning**, because the walk bound the name to `Http.get`, which is inside the commutative whitelist, and silently withheld the eager-evaluation warning the program is owed. Both are pinned with rename controls — the byte-identical program with the helper called `gett` / `fetch` was correct throughout — beside a control that an unshadowed bare `get(())` under a `State` row still warns, so the fix cannot degenerate into never reporting `State`. The walk's comment claiming it resolves "like the call checker above" is now true. One caveat the predicate did not close on its own: codegen's name table was not scope-accurate, so a name the call site cannot see still answered "user-owned" there — a property of the table rather than of the rule, closed by [#1299](https://github.com/aallan/vera/issues/1299) below. diff --git a/CLAUDE.md b/CLAUDE.md index 345faff2..e20048b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,10 +58,10 @@ VERA_JS_COVERAGE=1 pytest tests/test_browser.py -v # Browser tests with JS cove VERA_EAGER_GC=1 vera run file.vera # Force GC on every alloc (see ENVIRONMENT.md, debug knob for #593-class GC-rooting bugs) mypy vera/ # Type-check the compiler itself -python scripts/check_conformance.py # Verify all 229 conformance programs (positives pass their level; negatives fail with their expected_error E-code) +python scripts/check_conformance.py # Verify all 238 conformance programs (positives pass their level; negatives fail with their expected_error E-code) python scripts/check_examples.py # Verify all 42 examples parse + check + verify python scripts/check_examples_run.py # Run every runnable example trap-free under the native runtime; the rest carry a documented skip property, and an example that is neither is an error -python scripts/check_corpus_canonical.py # Verify all 278 corpus programs are in canonical form (vera fmt) +python scripts/check_corpus_canonical.py # Verify all 287 corpus programs are in canonical form (vera fmt) python scripts/check_examples_readme.py # Verify vera run commands in examples/README.md python scripts/check_spec_examples.py # Verify spec code blocks parse python scripts/check_readme_examples.py # Verify README code blocks parse @@ -93,7 +93,7 @@ See [`TOOLCHAIN.md`](TOOLCHAIN.md) for the CLI cookbook — driving the toolchai - `vera/` — Reference compiler: grammar, parser, AST, transformer, type checker, verifier, codegen, CLI - `examples/` — 42 example Vera programs (all must pass `vera check` and `vera verify`) - `tests/` — Test suite (unit tests + conformance suite) -- `tests/conformance/` — 229 conformance programs validating every language feature against the spec +- `tests/conformance/` — 238 conformance programs validating every language feature against the spec - `scripts/` — CI and validation scripts ## Writing Vera code @@ -130,7 +130,7 @@ Before changing code — **adding or removing** — write the test that proves y ## What not to break - Pre-commit hooks run mypy + pytest + conformance suite + example validation on every commit -- All 229 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first +- All 238 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first - All 42 examples in `examples/` must pass `vera check` and `vera verify` - Version must stay in sync across `pyproject.toml`, `vera/__init__.py`, `docs/index.html`, `README.md`, and `uv.lock` (gated by `scripts/check_version_sync.py`); CHANGELOG.md must also carry a matching `## [X.Y.Z]` section - All tests must pass: `pytest tests/ -v` diff --git a/FAQ.md b/FAQ.md index 77150dd1..bb68a428 100644 --- a/FAQ.md +++ b/FAQ.md @@ -236,7 +236,7 @@ None of this is Vera-specific, but it validates the design choices. The thesis i This is a real concern. LLMs are trained on trillions of tokens of Python, TypeScript, and JavaScript. A MojoBench study (NAACL 2025) found that even fine-tuned models achieved only 30–35% improvement over base models on Mojo code generation, illustrating the cold-start problem for new languages. -Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (229 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification. +Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (238 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification. ## How does Vera compare to Dafny / Lean / Koka / F*? @@ -279,7 +279,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 11,134 tests, including a 229-program conformance suite +- 11,237 tests, including a 238-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/README.md b/README.md index ed41e3f9..86b1f915 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ cp /path/to/vera/SKILL.md ~/.claude/skills/vera-language/SKILL.md ## Project status -Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 11,134 tests, 95% Python code coverage, 229 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. +Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 11,237 tests, 95% Python code coverage, 229 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. The reference compiler — parser, AST, type checker, contract verifier (Z3), WASM code generator, module system, browser runtime, and runtime contract insertion — is working. The language specification is in draft across [14 chapters](spec/). diff --git a/ROADMAP.md b/ROADMAP.md index 68919a7a..758f2b54 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,7 +12,7 @@ Ordering derives from the design principles ([DESIGN.md](DESIGN.md)): verificati ## Where we are -11,134 tests, 229 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. +11,237 tests, 238 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. ## Stage 19 — The verification completeness sprint diff --git a/SKILL.md b/SKILL.md index dff9d25f..05be366f 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1951,7 +1951,7 @@ Imported function contracts are verified at call sites by the SMT solver. Precon Cross-module compilation uses a flattening strategy: imported function bodies are compiled into the same WASM module as the importing program. The result is a single self-contained `.wasm` binary. Imported functions are internal (not exported); only the importing program's `public` functions are WASM exports. -If two of a namespace's imports supply the same bare name, `vera check` refuses it — E155 (function), E156 (data type), E157 (constructor) — reported at the second supplying import, in whichever file holds the clash. For a **function** name, resolve it either by narrowing one import (`import m(other_name);`) so a single supplier remains, or by declaring the name locally and reaching the imports with the module-qualified form (`m::name(...)`). For a **data type** or **constructor** name, rename the declaration in one of the two modules: compilation refuses two modules' same-named data declarations however the importer filters or shadows them, so no import-side change resolves those. The compile-time rails E608/E609/E610 remain as the backstop and refuse a wider set — any two modules in one program, not just two suppliers of one namespace. Local definitions shadow imported names without error. +If two of a namespace's imports supply the same bare name, `vera check` refuses it — E155 (function), E156 (data type), E157 (constructor) — reported at the second supplying import, in whichever file holds the clash. For a **function** name, resolve it either by narrowing one import (`import m(other_name);`) so a single supplier remains, or by declaring the name locally and reaching the imports with the module-qualified form (`m::name(...)`). For a **data type** or **constructor** name, rename the declaration in one of the two modules: compilation refuses two modules' same-named data declarations however the importer filters or shadows them, so no import-side change resolves those. The compile-time rails E608/E609/E610 remain as the backstop and refuse a wider set, reading declarations rather than any namespace's imports: E608 fires when two modules declare the same **function** name at any visibility — the ones that would share the flattened `$name` — unless both are top-level generics whose clones provably live in different module-qualified namespaces; E609 and E610 have no such exception, so any two modules' same-named `data` declarations or constructors collide however they are declared or imported. Local definitions shadow imported names without error. A module's data type may also collide with one the prelude provides (`Option`, `Result`, `Ordering`, `UrlParts`, `Json`, `HtmlNode`, `Request`, `Response`). One name carries one layout in the compiled program, so the two contend when their shapes differ — different constructors, a different constructor order, different field types, or a different number of type parameters (their names are free, since they are matched by position). The compiler then reports **E621** at the module's declaration; rename it there, or give it the prelude's shape. A module declaring `data Json` is legal on its own, because the prelude injects `Json` only when the entry program uses it; `Option`, `Result`, `Ordering` and `UrlParts` are in every program, so a differently-shaped module declaration of one of those always contends. Declaring the type in the **entry** file instead suppresses the prelude's own, so it never contends with the prelude — but it does not settle a clash with a module that also declares the name, which is a separate pair the compiler does not yet arbitrate ([#1312](https://github.com/aallan/vera/issues/1312)). @@ -2454,7 +2454,7 @@ public fn main(@Unit -> @Unit) ## Conformance Suite -The `tests/conformance/` directory contains 229 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. +The `tests/conformance/` directory contains 238 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. Each program is organized by spec chapter (`ch01_int_literals.vera`, `ch04_match_basic.vera`, `ch07_state_handler.vera`, etc.) and the `manifest.json` file maps features to programs. When you need to see how a specific construct works, check the conformance program before reading the spec. diff --git a/TESTING.md b/TESTING.md index 05cca78d..f671d05d 100644 --- a/TESTING.md +++ b/TESTING.md @@ -6,9 +6,9 @@ This is the single source of truth for Vera's testing infrastructure, coverage d | Metric | Value | |--------|-------| -| **Tests** | 11,134 across 169 files (~155,000 lines of test code; 10,955 passed + 26 stress, 153 skipped) | +| **Tests** | 11,237 across 170 files (~155,000 lines of test code; 11,042 passed + 26 stress, 169 skipped) | | **Compiler code coverage** | 95% Python, 87% JavaScript (CI minimum: 80%) | -| **Conformance programs** | 229 programs across 9 spec chapters, validating every language feature | +| **Conformance programs** | 238 programs across 9 spec chapters, validating every language feature | | **Example programs** | 42, all validated through `vera check` + `vera verify` | | **Spec code blocks** | 189 parseable blocks from 14 spec chapters: 92 parse, 86 type-check, 85 verify (the rest carry inline `vera:skip` annotations, #538) | | **README code blocks** | 4 Vera blocks (4 validated, 0 annotated) | @@ -39,7 +39,7 @@ VERA_EAGER_GC=1 pytest tests/test_codegen_closures.py::TestClosureReturnShadowPu mypy vera/ # strict mode # Validation scripts -python scripts/check_conformance.py # conformance suite (229 programs, see manifest.json) +python scripts/check_conformance.py # conformance suite (238 programs, see manifest.json) python scripts/check_examples.py # 42 example programs python scripts/check_spec_examples.py # spec code blocks python scripts/check_readme_examples.py # README code blocks @@ -84,8 +84,8 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_import_visibility_entry_point_1244.py` | 6 | 269 | #1244 — `vera check` reports the same diagnostics whether it was given a module or a file that imports it. Registration alone says what a module DECLARES; the importer never checked its bodies, so a name a module never imported was rejected standalone (E200, §8.5.1) and accepted in silence through an importer. Written as EQUALITY between the two entry points rather than as "the importer warns", because the property is agreement — a future change making the standalone verdict lenient would satisfy a one-sided assertion and must fail here on the standalone leg. Six cases: the leaked unimported name, the honest control that imports what it uses (green before and after, so the new body check is the visibility rule rather than a blanket rejection of cross-module programs), the issue's type-error-through-importer shape (an `@Int` call bound to a `@Bool` slot: check-clean, Tier-1, failing at compile), a diamond proving each module is reported ONCE (the body check is memoised by path across nested checkers), and both entry points into an import cycle proving the memo terminates it | | `test_clone_body_declaring_module_1241_1243.py` | 5 | 344 | #1241 + #1243 — an imported generic's clone body resolves its bare calls in the DECLARING module, on both sides. The verifier's lexical lookup fell through to the importer's registry (`_declaring_module_scope` swapped the naming env, source and file but not the function registry) and codegen's clone-emission door was the one door that did not thread the module's intra-rename map, so `glib`'s `gen` called the importer's `need`. The two halves are one routing rule, and the tests are written so neither passes alone: each case asserts the `vera verify` verdict AND the `vera run` value together, so the verifier half alone (which makes verify clean while the compiled program still traps on the postcondition it just proved — the measured false Tier-1) fails the same test the codegen half alone (right value, verify still refusing) fails. Every expected value comes from the module verified and run STANDALONE, never from what the importer produces. Shapes: a private direct callee, a two-hop private chain, the type-discriminating pair (`@Int` vs `@Bool` — check-green source that emitted invalid WASM), and the unshadowed-callee control that was correct before and after, which is what pins the defect to the SHADOWED name rather than to cross-module calls in general | | `test_module_generic_namespace_1274.py` | 24 | 1,015 | #1274 — a module generic that does not own the importer's bare name is reached under `mod$$name`. Pre-fix only PRIVATE module generics were routed that way (#1000), so a PUBLIC one collided with the importer's same-named generic in the clone-name space: both files' `gen2` mangled to one `gen2$Bool`, one overwrote the other, and the module's own body ran the importer's — a **false Tier-1** (`check`/`verify` clean, the module's proved `ensures` violated at run: 999 where the declaring module answers 111). The full visibility matrix (module generic × importer generic), the import-filter dimension (out-of-filter, in-filter, wildcard), the unshadowed-out-of-filter cell that assembled to `unknown func $gen2`, and a type-discriminating shape whose two clones have different WAT result types. Each cell asserts the verify VERDICT and the runtime VALUE together in one test — a clean verify beside a violated postcondition IS the bug, so splitting them across sibling tests would let it hide — against the standalone library as oracle, and re-checks that the importer's own generic still answers its own value. The per-module both-sides differential lives in `test_monomorphize_differential.py`. Two further families joined after the adversarial round: the module→module **hop** — a module's bare call to a DIFFERENT module's qualified-only generic, which the per-module classification never rerouted, in both a LOUD spelling (a contract pins the answer, so a captured call traps) and a SILENT one (every contract admits both answers, so only the value distinguishes them) plus the two-hop shape where the entry never imports the declaring module at all; and the **shared-input** pair, which pins that the two sides compute the importer's occupied bare names identically — codegen reads them after Pass 0's helper renames, the verifier from the pre-transform AST, and a non-generic `where`-helper named `gen2` made the same imported generic bare-name-owning on one side and qualified-only on the other. The idempotence of that derivation is asserted directly across BOTH Pass-0 transforms and their composition — over a fixture carrying every helper shape it distinguishes (non-generic under non-generic, under a generic parent, under a generic helper, and a generic helper), since a fixture missing one would let a partial assertion look total — with each shape's membership pinned individually beside it, because idempotence alone would hold for a derivation that answered the same WRONG set every time. Two more families close the visibility dimension: the **transitive** one, driven through the production `ModuleResolver` (a hand-built `ResolvedModule` defaults `direct=True` and would never reach the path), asserting that a module reached only transitively has ALL its generics qualified-only — the entry's namespace does not hold them at all; and the **user-written qualified call** (`deep::gen(true)` where the importer declares its own `gen`), which must key its instantiation to the module's declaration rather than to whoever owns the bare name | -| `test_ambiguous_import_refusal_1304.py` | 37 | 1,109 | #1304 — two imports supplying one bare name are refused, in every namespace. Spec §8.5 ordered a local declaration against an import and gave the qualified form for a clash it hides, but defined no order between two IMPORTS of one name, and neither did the implementation: a module importing two dependencies that each export `forall fn gen` — one `@Int`-returning, one `@Bool` — bound its bare call to whichever supplier a set of module paths yielded first, so one unchanged file was check-green on one run and `[E121] body has type Bool` on the next (at the branch point: accepted on hash seeds 0, 2 and 3, rejected on 1, 4, 5, 6 and 7). The load-bearing cells are the DETERMINISM ones — each import order checked in four fresh subprocesses under four `PYTHONHASHSEED` values, asserting one byte-identical verdict including message and location, which the base tree cannot satisfy and which a merely deterministic PICK would also fail (the refusal is what removes the choice). Around them: the refusal is definition-gated like the E608 rail it generalises, so an unused clash is still refused and swapping a bare call for the qualified form does not lift it; the two escape hatches — a local declaration (§8.5.2) and a selective import — are asserted to their RUNTIME VALUE, since a disambiguation resolving to the wrong supplier is silent at check and wrong at run; four non-ambiguous controls (one supplier, disjoint names, a private namesake, an out-of-filter namesake) hold the refusal to bare-name ambiguity; and the emitted code is held to a typecheck-phase range, because reusing a codegen code would carry #1304's own complaint — a scope question enforced at the wrong layer — into the fix. A subprocess canary pins that every fresh interpreter measures this checkout | -| `test_module_generic_collision_1281.py` | 20 | 797 | #1281 — E608 must not refuse two modules' PROVABLY DISTINCT generics. A generic emits nothing under its bare name, and since #1274 its clones live in a namespace chosen per OWNER, so the diamond (`base` public, `mid1` private, both named `gen`) cannot overwrite anything — and was refused outright, with `vera verify` returning rc=0 beside the refusal. Each door now answers its own module's generic (555 + 111) against the standalone oracles, and the emitted module carries `mod$…$mid1$gen$Bool` and `mod$…$base$gen$Bool` with nothing in the entry's bare clone namespace. The relaxation is gated on three conditions, each with its own cell: both declarations are top-level generics (a generic beside a non-generic keeps the refusal), at most one owns the bare name (asked of the predicate directly, since end to end the ambiguity gate catches that shape first), and no namespace can name both — a module importing two dependencies that each export `gen` would resolve its own bare call to one of them, and spec §8.5 refuses the name outright rather than ordering the two imports (issue 1304). The CHECKER reports that (E155) and this rail is its backstop, so both cells drive the shape through `build_multi_module_past_check` and assert both layers: a rail no test can reach is one that can rot into a relaxation nobody measures. A namespace that declares its own `gen` is not ambiguous however many dependencies export one (§8.5.2). The registration half — a qualified-only generic contributing no bare `_fn_sigs` or `_fn_ret_type_exprs` entry — is pinned by two STRUCTURAL cells, one per table, and its docstring says why: with #1299's scope narrowing in place both withholdings are defence in depth, reverting them leaves every suite and all 226 conformance programs green, and they are kept only because four consumers read those tables per NAME and nothing but their current internals stops each from picking one | +| `test_ambiguous_import_refusal_1304.py` | 40 | 1,197 | #1304 — two imports supplying one bare name are refused, in every namespace. Spec §8.5 ordered a local declaration against an import and gave the qualified form for a clash it hides, but defined no order between two IMPORTS of one name, and neither did the implementation: a module importing two dependencies that each export `forall fn gen` — one `@Int`-returning, one `@Bool` — bound its bare call to whichever supplier a set of module paths yielded first, so one unchanged file was check-green on one run and `[E121] body has type Bool` on the next (at the branch point: accepted on hash seeds 0, 2 and 3, rejected on 1, 4, 5, 6 and 7). The load-bearing cells are the DETERMINISM ones — each import order checked in four fresh subprocesses under four `PYTHONHASHSEED` values, asserting one byte-identical verdict including message and location, which the base tree cannot satisfy and which a merely deterministic PICK would also fail (the refusal is what removes the choice). Around them: the refusal is definition-gated like the E608 rail it generalises, so an unused clash is still refused and swapping a bare call for the qualified form does not lift it; the two escape hatches — a local declaration (§8.5.2) and a selective import — are asserted to their RUNTIME VALUE, since a disambiguation resolving to the wrong supplier is silent at check and wrong at run; four non-ambiguous controls (one supplier, disjoint names, a private namesake, an out-of-filter namesake) hold the refusal to bare-name ambiguity; and the emitted code is held to a typecheck-phase range, because reusing a codegen code would carry #1304's own complaint — a scope question enforced at the wrong layer — into the fix. A subprocess canary pins that every fresh interpreter measures this checkout | +| `test_module_generic_collision_1281.py` | 20 | 797 | #1281 — E608 must not refuse two modules' PROVABLY DISTINCT generics. A generic emits nothing under its bare name, and since #1274 its clones live in a namespace chosen per OWNER, so the diamond (`base` public, `mid1` private, both named `gen`) cannot overwrite anything — and was refused outright, with `vera verify` returning rc=0 beside the refusal. Each door now answers its own module's generic (555 + 111) against the standalone oracles, and the emitted module carries `mod$…$mid1$gen$Bool` and `mod$…$base$gen$Bool` with nothing in the entry's bare clone namespace. The relaxation is gated on three conditions, each with its own cell: both declarations are top-level generics (a generic beside a non-generic keeps the refusal), at most one owns the bare name (asked of the predicate directly, since end to end the ambiguity gate catches that shape first), and no namespace can name both — a module importing two dependencies that each export `gen` would resolve its own bare call to one of them, and spec §8.5 refuses the name outright rather than ordering the two imports (issue 1304). The CHECKER reports that (E155) and this rail is its backstop, so both cells drive the shape through `build_multi_module_past_check` and assert both layers: a rail no test can reach is one that can rot into a relaxation nobody measures. A namespace that declares its own `gen` is not ambiguous however many dependencies export one (§8.5.2). The registration half — a qualified-only generic contributing no bare `_fn_sigs` or `_fn_ret_type_exprs` entry — is pinned by two STRUCTURAL cells, one per table, and its docstring says why: with #1299's scope narrowing in place both withholdings are defence in depth, reverting them leaves every suite and the whole conformance corpus green, and they are kept only because four consumers read those tables per NAME and nothing but their current internals stops each from picking one | | `test_lexical_fn_scope_1299.py` | 56 | 1,554 | #1299 — codegen's bare-call ownership table must be the CALL SITE's lexical scope. The #1284 predicate is one rule over two tables, and codegen's was `set(_fn_sigs)`: every symbol the whole compilation absorbed, including names the compiling body cannot see, so a bare `get(())` the checker resolved to a `State` operation was lowered as a call to some other declaration. Four routes, all check-green — an imported module's **private** `get`, a **public** one a selective import excludes, a `where` helper of a **`forall` parent** (which keeps a bare key beside its clone-qualified one where a non-generic parent's does not), and the ability operation `show`, which E151 does not reserve and which reaches the same table through the INTRINSIC gate rather than the op one. Where the widths agreed the module loaded and answered the invisible declaration's value (7007 for the cell's 42007); where they differed it failed to load; the generic-`where` route is always loud (`unknown func $get`). Every expected value is the checker's, PROVEN by a type oracle rather than assumed — the invisible `get` returns `@Bool` while the caller returns `@Int` from it and checks green — and each route carries a rename control. The visibility matrix (public/private × in-filter/excluded/wildcard × shadowed/unshadowed × direct/transitive) asserts the verify verdict and the runtime value together per cell. The two directions are pinned at once: the sibling loses the name, the generic TEMPLATE keeps it (asserted on the emitted instruction stream, since monomorphization supersedes the template and a value assertion would be green either way), and a lifted closure — compiled through its own `WasmContext` — inherits its parent's scope. Four table invariants sit beside them: the scoped set is a subset of the registry, every `$`-bearing key stays in it, prelude names stay in it, and every emission door supplies a declaration its own helpers | | `test_phantom_generic_instances_1271.py` | 14 | 358 | #1271 — discovery inside a still-generic scope must not instantiate a callee at an ENCLOSING scope's type VARIABLE. `pick(@U.1, @U.0)` inside `forall fn helper` bound `pick`'s variable to the NAME `U`, so a `pick$U` clone was emitted whose parameter has no WASM type and which the compilability pass then skipped with a loud `[E604]` — the noise that kept #1223's shapes out of the conformance suite. Drives the four #1223 fixtures plus a mutual-recursion shape (two sibling generic helpers under a generic parent, whose phantoms include one arriving through a callee's declared RETURN type, `leaf$W`), asserting on ONE compile that no clone is keyed by a type variable, that no E602/E604/E605 skip is emitted, AND that the genuinely concrete clone is still there — the third assertion being what separates the filter from an over-filter that would take the real instantiation with it. Plus the **primitive-spelled binder** matrix (`forall`, ``, ``, ``), each row instantiating a sibling at exactly the type its binder is spelled like — a shared `idw(5)` would have let every row but `Int` pass for free — asserted on the clone set AND on the program still running; with the `Q`-binder control that keeps a genuine type variable filtered, so the fix cannot degenerate into "never filter". That control CREATES a live phantom candidate — a generic helper under a generic parent, handing its callee an argument typed by its own binder — because a control that merely fails to create one holds under any filter including none; mutation-checked by disabling the filter, which turns it red | | `test_handle_exn_divergent_result_1276.py` | 10 | 391 | #1276 — a `handle[Exn]` whose clause body AND handled body both diverge emitted a result-LESS `block` into a result-expecting context: check-green, verify-green, rejected at load with `type mismatch: expected i64 but nothing on stack`. Four divergent shapes (the issue's Int rethrow, the `Byte` payload spelling #1269 unmasked, a three-deep rethrow chain, and a clause diverging through both arms of an `if`), each asserted on valid WASM AND on the observable — the OUTER handler's clause value, 1000. Paired with the Unit TWIN, which infers `None` for the same reason but DOES complete: it must keep running and its WAT must contain no `unreachable` at all. The pairing is the point — `result_wt is None` means two things wanting opposite lowerings, and a fix that terminated both would trap a program that runs. The MIRROR family covers a clause that throws on one path and COMPLETES on the other (`if` and `match` spellings), where the inference read only the `then` branch / arm 0, answered `None`, and left the completing path's value stranded in a result-less block; the `if` case appears twice with different thrown values so both the throwing and the completing path are exercised from one inference | @@ -100,7 +100,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_checker_errors.py` | 73 | 1,196 | Error codes, resolution-coverage diagnostics, contracts, error accumulation (#420 split); cyclic type aliases incl. #1059 self-reference through a type argument (`Future`, mutual `Future`/`Future`, `Array`) rejected E132 | | `test_checker_builtins_collections.py` | 97 | 848 | Map / Set / Decimal / Json / Html / Http / Inference built-in type-checking (#420 split) | | `test_checker_builtins_strings.py` | 122 | 945 | String / numeric / type-conversion / float-predicate / string-search / markdown / regex built-in type-checking, removed-legacy-name regression (#420 split) | -| `test_obligations.py` | 750 | 1,741 | Reified proof obligations + warm `VerificationSession` (#222 Phase A): full-corpus differential oracle (warm session == cold `verify()` on diagnostics, summary, and obligation stream, plus warm-twice determinism, across all 42 examples and every verify/run-level conformance program), summary↔obligation tier-bookkeeping consistency (including the #967 `total == tier1_verified + tier3_runtime` leg, plus a focused self-consistency pin on the three call-demotion examples), the #1242 stream partition — over a corpus widened to every conformance program that type-checks, at any level, `len(obligations) == total + violated + tier3_unguarded` and every status is one of the documented five, with the vocabulary read from the `ObligationStatus` Literal so a sixth member fails rather than vanishing from the counts — per-kind unit tests (requires / ensures / decreases / nat_sub / call_pre statuses, counterexamples, error codes), content-key stability + same-text-two-sites span disambiguation, session solver reuse, type-error short-circuit, ADT-registry resync between programs; plus the Phase B incremental suite — identical-source full replay, callee-body-edit replays callers while callee-contract-edit invalidates them, span-shift and ADT-edit conservative invalidation, cross-program isolation, timeout-status never cached (monkeypatched solver), FIFO eviction bound; plus the #727 dedup pin — a violating call in a let RHS records exactly one E501 diagnostic and one call_pre obligation; plus the #1208 call-site rendering pin — a PARAMETERISED callee slot substitutes into the E501 message and its fix instead of falling back to the generic wording | +| `test_obligations.py` | 757 | 1,741 | Reified proof obligations + warm `VerificationSession` (#222 Phase A): full-corpus differential oracle (warm session == cold `verify()` on diagnostics, summary, and obligation stream, plus warm-twice determinism, across all 42 examples and every verify/run-level conformance program), summary↔obligation tier-bookkeeping consistency (including the #967 `total == tier1_verified + tier3_runtime` leg, plus a focused self-consistency pin on the three call-demotion examples), the #1242 stream partition — over a corpus widened to every conformance program that type-checks, at any level, `len(obligations) == total + violated + tier3_unguarded` and every status is one of the documented five, with the vocabulary read from the `ObligationStatus` Literal so a sixth member fails rather than vanishing from the counts — per-kind unit tests (requires / ensures / decreases / nat_sub / call_pre statuses, counterexamples, error codes), content-key stability + same-text-two-sites span disambiguation, session solver reuse, type-error short-circuit, ADT-registry resync between programs; plus the Phase B incremental suite — identical-source full replay, callee-body-edit replays callers while callee-contract-edit invalidates them, span-shift and ADT-edit conservative invalidation, cross-program isolation, timeout-status never cached (monkeypatched solver), FIFO eviction bound; plus the #727 dedup pin — a violating call in a let RHS records exactly one E501 diagnostic and one call_pre obligation; plus the #1208 call-site rendering pin — a PARAMETERISED callee slot substitutes into the E501 message and its fix instead of falling back to the generic wording | | `test_verifier_contracts.py` | 95 | 898 | Z3 verification over the example corpus, trivial/ensures/if-else/let/multi-clause contracts, counterexamples, tier classification, arithmetic, verification summaries, Diverge effect, edge cases, string-length + string-predicate verification (#839 split) | | `test_verifier_nat_obligations.py` | 82 | 1,743 | **`@Nat` subtraction underflow obligation** (#520 — Path-A discharge via requires/path-conditions/path-aware Z3 refutation, pure-literal exclusion, Int-Int and Nat-Int exemptions) and **`@Nat` binding-site narrowing obligation** (#552/#747/#749 — Tier-1 `value >= 0` at let/call-arg/effect-op-arg/ctor-field/match-bind/destructure narrowing — a concrete site classifies `tier3_runtime` (codegen-guarded) while the effect-op argument and generic-instantiated constructor field classify `E504` (obligated but unguarded, #754/#757) whose rationale names its actual cause — an untranslatable value — rather than the untranslatable-or-timeout conflation #1251 removed, walker-recursion pins, `_narrows_into_nat` verifier/codegen soundness parity; PR #972 clone-instantiated side-table substitution — a `Some(@T)` bind in an `Option`-instantiated clone is no narrowing, genuine clone-path narrowings still obligated); #1201 — a builtin `Tuple` parameter's match-bound components carry their declared component facts (a valid ensures over one proves instead of falsely violating) and an `Int` component bound as `@Nat` fires one loud `E503` per component, both mutation-caught (#839 split) | | `test_verifier_primitive_ops.py` | 39 | 662 | **Primitive-operation safety obligations** (#680) — division/modulo by-zero `E526` and array-index-bounds `E527`, the in-bounds/out-of-bounds two-check with float-exemption, honest Tier-3 for opaque lengths, off-by-one and lower-bound pins, De Bruijn-correct fix hints (#839 split) | @@ -118,7 +118,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_soundness_392.py` | 36 | 584 | #392 audit batches 1–2 — verifier soundness/completeness fixes: signed div/mod truncate toward zero (#799), body `assert(P)` carries a Tier-1 obligation (#800), divisions in contract predicates carry a `div_zero` obligation (#801), and the #804 assume-half of #800's `assert` rule — a prior `assert`/`assume` discharges later obligations (including a later call's precondition) + the postcondition at Tier 1, removing false E501/E503/E500/E505 | | `test_int_overflow.py` | 6 | 143 | #798 — `@Int`/`@Nat` arithmetic-overflow obligations (part of the #392 `smt.py` soundness audit): `+`/`-`/`*` on `@Int`/`@Nat` now emit an `int_overflow` obligation (the analog of `nat_sub`/`div_zero`) rather than modelling the operands as Z3's unbounded integers, so a `ensures(@Int.result > @Int.0)` over `@Int.0 + 1` no longer proves a contract the i64/u64 runtime violates under two's-complement wraparound. Unbounded operands leave the obligation undischarged (Tier-3, runtime-guarded); operand bounds that prove the result stays in range discharge it at Tier 1 | | `test_int_overflow_codegen.py` | 62 | 718 | #798 Stage 3 — runtime overflow-trap codegen: the codegen emits a guard at *exactly* the `@Int`/`@Nat` `+`/`-`/`*` sites the verifier obligates, so `vera run`/`vera compile` programs trap on overflow instead of silently wrapping at the i64/u64 boundary. #808 wired the guard to the `vera.overflow_trap` host import, so the trap now classifies `kind="overflow"` (carrying the overflow Fix paragraph) rather than the generic `unreachable`; `TestOverflowTrapKind808` pins that, with controls proving the #520 `nat_sub` underflow and #813 `@Nat`→`@Int` widen guards still classify `unreachable` | -| `test_int_overflow_differential.py` | 252 | 398 | #798 Stage 3 verifier↔codegen classification differential (cross-component soundness rule): the codegen overflow guard must fire at exactly the sites the verifier obligates *and* classify each site's operand type (`@Int` i64 vs `@Nat` u64) identically — else a Tier-1-clean program traps spuriously or a wrapping op slips through unguarded. Over a corpus exercising all five operand combos plus the literal-left ambiguity (a naive codegen mis-classifies it as `@Nat`), asserts the verifier's per-site gated classification equals the codegen's site for site, both sides driven by the same `ast.span_key` | +| `test_int_overflow_differential.py` | 253 | 398 | #798 Stage 3 verifier↔codegen classification differential (cross-component soundness rule): the codegen overflow guard must fire at exactly the sites the verifier obligates *and* classify each site's operand type (`@Int` i64 vs `@Nat` u64) identically — else a Tier-1-clean program traps spuriously or a wrapping op slips through unguarded. Over a corpus exercising all five operand combos plus the literal-left ambiguity (a naive codegen mis-classifies it as `@Nat`), asserts the verifier's per-site gated classification equals the codegen's site for site, both sides driven by the same `ast.span_key` | | `test_nat_int_widening.py` | 36 | 602 | #813 — `@Nat -> @Int` widening coercion obligation (dual of #552 `nat_bind`, part of the #392 soundness audit): a `@Nat` in (i64.MAX, u64.MAX] reinterprets when widened (u64.MAX → -1), so a `nat_to_int_coerce` obligation that the value is `<= i64.MAX` now fires at the return position — provably-in-range → Tier-1, provably-out-of-range (`@Nat.0 >= 2**63`) → loud E530, unbounded → honest Tier-3 (runtime-guarded), with an `@Int -> @Int` control that must not fire; the unguarded generic-`@Int`-field case also has its `E531` rationale read for WHAT IT SAYS — a value bounded on neither side, not the untranslatable-or-timeout conflation #1251 removed. The #813 follow-up adds the explicit `nat_to_int` built-in and heterogeneous `if`/`match` arms with a non-negative-literal alternative; #820 adds the heterogeneous-`@Int`-slot arm, closure argument, and closure return/capture obligations (each per-arm / per-site, with `@Int`-arm and `@Nat`-formal controls that must not fire) | | `test_int_widening_codegen.py` | 52 | 535 | #813 Stage 3 — runtime `@Nat -> @Int` widening-trap codegen: the codegen emits a guard at *exactly* the `@Nat -> @Int` coercion sites the verifier obligates (return, `let`, call argument, and — since #820 — array element, tuple construction/destructure, heterogeneous `if`/`match` arm, closure argument/return), so `vera run`/`vera compile` programs trap when a `@Nat` above i64.MAX would reinterpret to a negative `@Int` instead of silently returning the wrong value. The trap is a bare `unreachable` (shares `_emit_negative_i64_guard` with the #552 nat-bind guard), classified `kind="unreachable"` today (a dedicated widening trap kind is a follow-up) | | `test_int_widening_differential.py` | 26 | 320 | #813 verifier↔codegen behavioural differential (cross-component soundness rule): at every `@Nat -> @Int` coercion site the verifier's `nat_to_int_coerce` classification must AGREE with the runtime — a `tier3` (codegen-guarded) site MUST trap on a `@Nat` above i64.MAX (return / `let` / call-arg / constructor field / ADT sub-pattern / match-bind, and the #820 array-element / tuple-component / heterogeneous-arm / closure argument-return sites), while a `tier3_unguarded` (E531) site must NOT trap (the generic-instantiated `@Int`-field coercion codegen cannot guard). Runs BOTH sides on one corpus so the "runtime-guarded" claim is checked against the actual trap — catching a verifier deferral codegen never guards (unsound silent -1) or a spurious trap | @@ -184,7 +184,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_string_length_soundness.py` | 15 | 278 | #802 — string_length code-point vs UTF-8 byte soundness: a non-literal `string_length` defers to Tier 3 (the issue's `"é"` probe no longer proves `== 1` at Tier 1), a string-literal length is modeled at its exact UTF-8 byte count (`== 2` for `"é"`), and the boolean predicates `string_contains` / `string_starts_with` / `string_ends_with` stay Tier 1 (sound under UTF-8 self-synchronization), while a predicate over an astral (> U+2FFFF) or lone-surrogate literal defers to Tier 3 (z3.StringVal cannot model those code points) | | `test_errors.py` | 62 | 657 | Error code registry, diagnostic formatting, serialisation, SourceLocation, and error display sync — the canonical `E001` diagnostic must match each of its mirrors: `README.md`, `docs/index.html`, `spec/00-introduction.md`, `AGENTS.md`'s example `--json` block, and the hardcoded example in `scripts/build_site.py` that generates `docs/index.md` (#829; `AGENTS.md`'s ellipsis-truncated description/rationale are prefix-compared, its `error_code`/`spec_ref`/`fix` exactly) | | `test_eq_contract_874.py` | 13 | 430 | `eq`/`compare` ability ops in contract position: codegen canonicalization + verifier Tier-1 discharge/counterexample, where-fn contracts, compare Ordering-sort materialization, shadowing guard (#874) | -| `test_formatter.py` | 524 | 3,638 | Comment extraction, interior comment positioning, expression/declaration formatting, match arm block bodies, §1.8 rule 2 in value position (a `let`-bound `match`/`if` expands exactly as one in statement position, and a comment above an arm inside a statement's value stays on that arm), blank-line preservation (§1.8 rule 13 — gaps between statements, before a block result and around a comment, collapsed to one and never invented), idempotency, parenthesization, spec rules, ability declarations | +| `test_formatter.py` | 533 | 3,638 | Comment extraction, interior comment positioning, expression/declaration formatting, match arm block bodies, §1.8 rule 2 in value position (a `let`-bound `match`/`if` expands exactly as one in statement position, and a comment above an arm inside a statement's value stays on that arm), blank-line preservation (§1.8 rule 13 — gaps between statements, before a block result and around a comment, collapsed to one and never invented), idempotency, parenthesization, spec rules, ability declarations | | `test_cli.py` | 273 | 4,612 | CLI commands (check, verify, compile, run, serve, test, fmt, version, quiet), subprocess integration, JSON error paths (including the `verify --json` `obligations` array and its summary-reproducibility pin, #967, and the #1242 partition pin — the array is emitted unfiltered, a refuted obligation is counted by no summary field, and it still joins its E500 on the location key), runtime traps, arg validation, multi-file resolution, IO exit codes, --explain-slots (including the #1208 naming pins — an alias in type-argument position is tabled resolved, and a `forall` variable shadowing a module alias keeps the two parameter stacks apart — and the #1217 `where`-helper tables: the helper prints indented under its parent, appears in the JSON qualified as `parent.helper`, and inherits the enclosing `forall` variables so the shadowing holds inside it too), `builtins`/`effects`/`errors` introspection dispatch, and a USAGE-completeness guard (every dispatched `cmd_` handler has a help row) | | `test_introspect.py` | 39 | 221 | `vera builtins/effects/errors --json` registry introspection (#539): the `{schema, items}` envelope, count-equals-registry differential per registry, error-phase derivation, effect/ability `kind` tagging, the parameterised `Exn` effect, and best-effort `since` attribution with full-coverage guards | | `test_resolver.py` | 20 | 602 | Module resolution, path lookup, parse caching, circular import detection, the E011/E012/E013 diagnostic contract, internal-error isolation (a compiler bug is not masked as E013), and the transitive-closure return of `resolve_imports` (#890 — a diamond yields each reachable module once, direct imports tagged `direct`, the transitive one not) | @@ -202,7 +202,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_markdown.py` | 94 | 610 | Markdown parser: block/inline parsing, rendering, round-trips, edge cases | | `test_lsp.py` | 146 | 2470 | LSP transport + coordinate layer (#222 Phase C) and language features (#222 Phase D): parametrized code-point↔UTF-16 goldens incl. astral-plane fixtures and surrogate-pair snapping, Span (1-based, exclusive-end) and SourceLocation (0-based col) → LSP Range conversions, point→token-range widening, DocumentStore open/change/close + index invalidation, an in-process handler-drive test, and one stdio end-to-end round-trip against the real `vera lsp` subprocess (initialize → didOpen → shutdown → exit) pinning serverInfo + textDocumentSync capabilities; plus the Phase D feature suite — parse-error single-diagnostic path, type-error verification short-circuit, tier=3 in E520 diagnostic data, per-function tier Hint synthesis (and its suppression for functions with violated obligations), smallest-enclosing-span hover, De Bruijn slot goto (most-recent-parameter jump, out-of-range None, off-slot None, and the #1208 keying pins: a parameterised reference resolves, an alias-spelled parameter is reachable from a canonically-spelled reference, and a `forall` variable shadowing a module alias lands on the right parameter), and typed-hole completion (inside/after hole, away-from-hole None); plus the Phase E speculativeEdit suite — identical-text all-unchanged, breaking edit surfaces newly_undischarged (violated nat_sub) with canonical state untouched, strengthening edit surfaces newly_discharged, parse/type errors report ok:false, deleted functions report removed, proof_delta purity; plus the Phase F1 proposeEdit suite — the apply gate (clean and strengthening edits apply, breaking and non-compiling edits refuse), force overriding both gates with the delta still reported, wiring against a structural fake server (apply round-trip with exact full-document replacement range, refuse touches no canonical state, unopened-URI clamp sentinel), and full-document-range goldens (trailing-newline virtual line, UTF-16 end column); plus the Phase F2 strengthenContract suite — splice goldens (first-clause-only replacement with byte-identical remainder, ensures variant, unknown-fn None), the call-site audit pin (tightened precondition refused with newly_undischarged call_pre items, canonical state untouched), provable-ensures strengthening applies, and the three splice-target refusal paths (no analysis, unparseable document, unknown function); plus the Phase F3 addEffect suite — transitive-caller closure goldens (diamond in declaration order, leaf, unknown-fn None, recursion appears once), handler bounding (#725: a caller discharging the effect around its only call site drops out of the closure and is not rewritten, while a second unhandled path, a call in a handler clause, a handler for another effect, a handler naming a different instance of the same effect (`handle[State]` against a `State` propagation, which the checker does not discharge — end-to-end that caller must still be rewritten and the candidate must still apply, with the matching `State` propagation against the same fixture as the positive control that this handler key does prune something), and a bare `where`-helper call all keep it in, as does a refinement type argument at either depth (`Exn<{ @Int \| p }>` and `Exn>` render as their bare base type but discharge nothing of `Exn` — also pinned end-to-end), while an unparameterised `handle[IO]` does bound an `IO` propagation, a whitespace-spelled `State< Int >` request still bounds, and nesting bounds in either order (a matching handler inside a foreign one, a foreign one inside a matching one) — plus a key-level pin that `handle[Mod.IO]` keeps its module, and the effect-less query pinned handler-unaware; plus the two boundary pins the review added — a call in the handler's STATE INITIALISER keeps its edge, since the initialiser is evaluated in the enclosing scope before the handler is installed (pinned beside the E125 the checker raises there against a `pure` caller, with the identical call in the handler body clean as the contrast), and an alias-spelled handler does not bound a `State` propagation though the checker discharges it (`handle[State]` with `type MyAlias = Int` — the spelling comparison's under-prune, #1292, with the alias-spelled request as the control that does prune)), effect-row rewrite goldens (pure to singleton set, source-preserving append, already-present None, base-name identity blocking State next to State), diamond propagation applying one multi-site candidate with the bystander untouched, mixed append/replace rows with already-satisfied callers skipped, the fully-satisfied no-op shape, and the two refusal paths; plus the #728 instruction-contract suite — the LSP message carries description, rationale, and the Fix: paragraph (also pinning single E501 emission at the LSP surface), and a bare diagnostic maps to the description alone | | `test_browser.py` | 347 | 4,733 | Browser parity: Python/wasmtime vs Node.js/JS-runtime output equivalence across IO, State, contracts, Markdown, Regex, and the examples the browser target can execute (two explicit lists in the file, not the whole `examples/` directory — interactive stdin, file IO, `DB` and the non-standalone `modules` example are excluded with their reasons recorded); plus the #349 `runtime.mjs` coverage battery — per-value-type and per-key-type Map variants, per-element-type Set variants, cold `Decimal` branches (exact-zero sign, negative-shift division, `decRoundPlaces` special cases, non-finite storage), `readJson`/`json_stringify` across every Json ADT tag, the Regex/Json `Result.Err` arms, and nested-Markdown walks. Two operations carry a canonical form the specification states rather than merely agreeing across the hosts, so their batteries assert more than equality: `json_stringify` (spec §9.7.1) pins the expected string on every Json ADT tag and on the number-rendering boundaries, checks three-pass idempotence, checks that a non-finite `JNumber` fails on both hosts *and* prints nothing, pins the object key orders an ordinary JS object cannot carry (array-index keys, which ES enumeration hoists to the front in ascending numeric order, and a `__proto__` key, whose assignment writes a prototype instead of a field) on both a parsed and a program-built object, and checks the reference host's own number rendering differentially against a real `JSON.stringify` over doubles drawn from raw bit patterns; `md_render` (§9.7.3) pins the expected render, re-renders it to prove the fixed point, runs the round-trip property over a corpus carrying the container and multi-line shapes a flat corpus misses, and renders `MdBlock` values the test builds directly, since several renderer rules — a container's child separator, an empty container, a code span wider than one backtick — are unreachable through `md_parse`. `md_parse` itself is not yet at parity and its remaining divergence classes are a tracked bug ([#1301](https://github.com/aallan/vera/issues/1301)); the suite pins the shapes the two implementations agree on | -| `test_conformance.py` | 1145 | 144 | Parametrized conformance suite: parse, check, verify, run, format idempotency across 229 programs; a negative entry fails at the stage `expected_error_stage` names (`check`, the default, or `compile` — which also asserts the program type-checks cleanly first) | +| `test_conformance.py` | 1190 | 144 | Parametrized conformance suite: parse, check, verify, run, format idempotency across 238 programs; a negative entry fails at the stage `expected_error_stage` names (`check`, the default, or `compile` — which also asserts the program type-checks cleanly first) | | `test_prelude.py` | 29 | 585 | Prelude injection: Option/Result/array operation detection, combinator shadowing, type aliases, the reserved namespace every injected alias declaration lives in — checked against the checker's own E154 regex rather than a second spelling of the rule, since an alias the prelude injects outside it is one codegen resolves and the checker leaves opaque (#1184/#1221) — end-to-end compilation | | `test_checker_apply_fn.py` | 18 | 455 | #854 — `apply_fn` as a checker special form: zero-warning pins (API + CLI `--json` + closures.vera), E201 arity / E202 type / non-function-first-arg errors, E122/E125 effect-row enforcement for applied fn values, E151 redefinition rejection, variadic two-param application, prelude combinator regression pins | | `test_prelude_diagnostics.py` | 8 | 271 | #851 — prelude combinator skip-warnings: unreferenced-prelude E602/E604 suppression (zero-warning minimal compile, API + CLI `--json`), `` origin attribution for referenced-but-skipped combinators (text + `to_dict`), transitive reference scan, and user-fn warning locations pinned unchanged | @@ -224,11 +224,11 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_doc_builtin_shadowing.py` | 8 | 107 | `check_doc_builtin_shadowing.py` gate ([#819](https://github.com/aallan/vera/issues/819)): reject-set membership (opaque built-ins in, overridable combinators out), top-level + `where`-block `fn ` definitions flagged, non-built-in / overridable / prose-mention ignored, and the shipped docs are currently clean | | `test_runtime_traps.py` | 75 | 2,877 | Runtime trap categorisation (#516 Stage 1), out-of-bounds host-read bounds check (#1145), stdout/stderr-on-trap preservation (#522), `IO.print` live tee (#543), and trap source backtrace (#516 Stage 2): `_classify_trap` per-`kind` mapping (`divide_by_zero`/`out_of_bounds`/`stack_exhausted`/`unreachable`/`overflow`/`contract_violation`/`unknown`), `WasmTrapError` shape + `RuntimeError` substitutability, end-to-end `cmd_run` text + JSON envelopes including `trap_kind`, captured `stdout`, captured `stderr`, JSON-mode "no stderr leak" invariant, cross-stream code-order regression using merged `redirect_stdout`/`redirect_stderr`, the v0.0.123 tee suite (live streaming, write-count + order preservation, JSON-mode tee suppression, trap preservation invariant under tee, per-write flush count, default-execute silence), and the v0.0.124 source-mapping suite — `_resolve_trap_frames` unit tests covering user-fn / built-in / built-in-prefix / monomorphized base-name fallback / unknown-name / no-frames-attribute / leaf-first ordering preservation; end-to-end `cmd_run` text-mode + JSON-mode backtrace including the **leaf-first** ordering invariant; contract-violation backtrace in both text and JSON modes; direct `execute()` `WasmTrapError.frames` attachment; **suppression marker** for collapsed leading runtime-helper frames (mocked `vera.codegen.execute` with synthetic `is_builtin=True` leaf frames so the collapse logic is testable deterministically); source-map population for top-level fns + lifted closures (with span-value assertion against the closure literal's exact line range); and the no-builtin-leakage regression that pins built-in helpers (`alloc` / `gc_collect` / `contract_fail`) NOT being registered in `fn_source_map`; plus the v0.0.125 Stage 3 suite (`#547`) — text-mode `Fix:` block surfacing with position-ordering invariant (Fix appears after the source backtrace), text-mode block suppression for `contract_violation` (no empty header noise), JSON-mode `fix` field always-present (schema stability) including the empty-string case, `_TRAP_FIX_PARAGRAPHS` table-completeness assertion (every kind in the taxonomy has a Fix paragraph entry), and the column-wrap invariant (~76 chars max per line, two-space indent under the `Fix:` heading); plus the UTF-8 hardening suite **`TestHostPrintInvalidUtf8589`** (`#589` / `#592`) — after `#592` centralised the `errors="replace"` invariant into the single `vera.runtime.text.safe_utf8_decode` helper — reached only through a shared `_slice_and_decode` helper (`vera/runtime/heap.py`) that the three WASM-memory string readers (`_read_wasm_string` and `_read_string_export` there, and `vera/wasm/markdown.py::_read_string`) delegate to, with the `host_print` / `host_stderr` / `host_contract_fail` host imports and the String-return extractor in `execute()` routing through those readers rather than decoding inline: one helper unit test pinning the invariant once (invalid bytes → U+FFFD, valid + empty pass through), three wire-real end-to-end tests that drive the **production** readers (`_read_wasm_string` / markdown `_read_string` behind a synthetic-WAT `probe` host import; `_read_string_export` against a real exported memory, also covering its out-of-bounds → `None` pointer-fallback) over a region seeded with invalid UTF-8 — so a strict-decode regression surfaces as a `UnicodeDecodeError` escaping wasmtime's trampoline, and the host imports / extractor are transitively covered — and one synthetic-WAT end-to-end test that imports `vera.print` and calls it with raw invalid UTF-8 bytes to pin the wasmtime-trampoline fact independently (a Python `UnicodeDecodeError` inside a host import escapes as a "python exception" cause iff the host decode is strict); the six pre-`#592` structural source-grep assertions were retired by the centralisation; plus the Ctrl-C-during-host-import suite **`TestHostSleepKeyboardInterrupt`** ([#595](https://github.com/aallan/vera/issues/595) / [#599](https://github.com/aallan/vera/issues/599)) — after the v0.0.160 relocation to a single `except KeyboardInterrupt` handler in `execute()` (enabled by `wasmtime>=45.0.0`'s `except BaseException` trampoline fix): one structural assertion that the four per-host-import `raise _VeraExit(130)` guards are gone and the centralized handler maps to `exit_code=130`, plus four end-to-end tests that compile real Vera programs calling `IO.sleep(...)`, `IO.read_char(())`, a mocked fused `await` ([#841](https://github.com/aallan/vera/issues/841) — `Future.result()` patched to interrupt), and a live in-flight fused `await` (no mocking — `_thread.interrupt_main()` fired only once the server confirms the request arrived, handler then released so the executor teardown has a real worker to wait out; post-[#848](https://github.com/aallan/vera/issues/848) the progress print precedes the `async(...)`, so program order makes its stdout assertion deterministic), raise `KeyboardInterrupt` from inside the blocking call, and assert the program exits with `ExecuteResult.exit_code == 130` (pre-interrupt stdout preserved) instead of a raw Python traceback escaping wasmtime's trampoline | | `test_serve.py` | 8 | 189 | #305 `vera serve` driver end-to-end: GET/POST echo round-trips (method/path/headers/body cross the host↔guest boundary via `build_request_adt` / `decode_response_adt`), handler status propagation, runtime contract violation → 500 with `trap_kind` JSON, `State` isolation across requests (instance-per-request pinned), and clean `make_server` validation errors (missing / wrong-signature `handle`), and an eager-GC round-trip pinning the Request builder's shadow-rooting; all on ephemeral ports | -| `test_wasi_target.py` | 269 | 2,142 | #237 WASI Preview 2 target (spec chapter 13): component emission validated live against the real wasmtime host — parse (`Component(engine, wat)`), instantiate (`Linker.add_wasip2()` + `WasiConfig`), and execute (stdout/stderr capture, env, argv incl. a 500-arg GC-pressure stress and a >64 KiB arena-cap trap, preopen file round-trips + errno mapping, stdin incl. UTF-8 multibyte, clocks, random bounds, exit, contract-violation text on WASI stderr, overflow); the family gate (clean diagnostic naming unsupported families, never a silent fallback); the core-emission pin (default `--target wasm` WAT untouched); `cmd_compile`/`cmd_run --target wasi-p2` CLI integration (binary component artifact, `--wat` component text, JSON envelopes, trap-kind classification through the component boundary, exit-code 0/1 degradation, `--fn` rejection); the `execute_wasi_p2` host runner (env passthrough, argv, stderr capture, String-main `wasi:cli/run` fallback); the **dual-target conformance differential** (all 168 run-level conformance programs driven under both targets, byte-identical stdout/stderr required — 118 are dual-tested and 50 skip *loudly* rather than passing silently: 43 whose compiled WAT imports a host family outside `IO`/`Random` (`state`, `map`, `json`, `set`, `decimal`, `html`, `md`, `regex`, `db`), 6 with no public zero-argument `main`, and 1 calling a nondeterministic op. The excluded set is defined by those three properties rather than by a filename list, so it stays accurate as programs are added); a stock-`wasmtime`-CLI smoke test (skips when the CLI is not installed); and the Stage-D **server world** (`world="server"`): incoming-handler emission pins (adapter lift, 32-slot dispatch table, @0.2.0 version pin, no `wasi:cli/run`), #305 handler validation + server family-gate diagnostics (rejected IO ops, non-String map instantiations, unsupported families), the cli-world pin (default emission carries no server machinery), Request/Response layout tripwires, and a stock-`wasmtime serve` smoke battery (host-vs-served differential over a method/path/header/body matrix incl. duplicate-header later-wins, in-guest map-op order parity, `IO.print` console routing, trap→500 with symbolized backtrace + violation text, graceful 500s for forbidden headers and out-of-range status, a 1 MiB GC-stress echo, and an eager-GC shadow-push mutation validation; skips when the CLI is not installed) | +| `test_wasi_target.py` | 270 | 2,142 | #237 WASI Preview 2 target (spec chapter 13): component emission validated live against the real wasmtime host — parse (`Component(engine, wat)`), instantiate (`Linker.add_wasip2()` + `WasiConfig`), and execute (stdout/stderr capture, env, argv incl. a 500-arg GC-pressure stress and a >64 KiB arena-cap trap, preopen file round-trips + errno mapping, stdin incl. UTF-8 multibyte, clocks, random bounds, exit, contract-violation text on WASI stderr, overflow); the family gate (clean diagnostic naming unsupported families, never a silent fallback); the core-emission pin (default `--target wasm` WAT untouched); `cmd_compile`/`cmd_run --target wasi-p2` CLI integration (binary component artifact, `--wat` component text, JSON envelopes, trap-kind classification through the component boundary, exit-code 0/1 degradation, `--fn` rejection); the `execute_wasi_p2` host runner (env passthrough, argv, stderr capture, String-main `wasi:cli/run` fallback); the **dual-target conformance differential** (all 169 run-level conformance programs driven under both targets, byte-identical stdout/stderr required — 119 are dual-tested and 50 skip *loudly* rather than passing silently: 43 whose compiled WAT imports a host family outside `IO`/`Random` (`state`, `map`, `json`, `set`, `decimal`, `html`, `md`, `regex`, `db`), 6 with no public zero-argument `main`, and 1 calling a nondeterministic op. The excluded set is defined by those three properties rather than by a filename list, so it stays accurate as programs are added); a stock-`wasmtime`-CLI smoke test (skips when the CLI is not installed); and the Stage-D **server world** (`world="server"`): incoming-handler emission pins (adapter lift, 32-slot dispatch table, @0.2.0 version pin, no `wasi:cli/run`), #305 handler validation + server family-gate diagnostics (rejected IO ops, non-String map instantiations, unsupported families), the cli-world pin (default emission carries no server machinery), Request/Response layout tripwires, and a stock-`wasmtime serve` smoke battery (host-vs-served differential over a method/path/header/body matrix incl. duplicate-header later-wins, in-guest map-op order parity, `IO.print` console routing, trap→500 with symbolized backtrace + violation text, graceful 500s for forbidden headers and out-of-range status, a 1 MiB GC-stress echo, and an eager-GC shadow-push mutation validation; skips when the CLI is not installed) | ## Conformance Suite -The conformance suite is a collection of 229 small, focused programs in `tests/conformance/` that systematically validate every language feature against the spec. Most programs are self-contained; the module-focused Chapter 8 cases use `import` statements where needed, and `ch07_cross_module_contracts.vera` still depends on `ch07_cross_module_contracts_lib.vera`. Each program tests one feature or a small group of related features. +The conformance suite is a collection of 238 small, focused programs in `tests/conformance/` that systematically validate every language feature against the spec. Most programs are self-contained; the module-focused Chapter 8 cases use `import` statements where needed, and `ch07_cross_module_contracts.vera` still depends on `ch07_cross_module_contracts_lib.vera`. Each program tests one feature or a small group of related features. Simon Willison [argues](https://simonwillison.net/tags/conformance-suites/) that conformance suites are a "huge unlock" for language projects — they transform development from trust-based to verification-based. The conformance suite serves as the definitive specification artifact that any implementation (or agent) can validate against. @@ -401,7 +401,7 @@ tests/conformance/ ├── ch01_int_literals.vera # Chapter 1: Integer literals ├── ch01_float_literals.vera # Chapter 1: Float64 literals ├── ch01_string_escapes.vera # Chapter 1: String escape sequences -├── ... # 229 programs total, organized by spec chapter +├── ... # 238 programs total, organized by spec chapter ├── ch07_state_handler.vera # Chapter 7: State effect handler ├── ch07_exn_handler.vera # Chapter 7: Exn effect handler ├── ch09_numeric_builtins.vera # Chapter 9: Numeric built-in functions @@ -435,7 +435,7 @@ The manifest is the machine-readable feature inventory — agents can query it t ### Running the conformance suite ```bash -# Via pytest (parametrized — 1,145 tests: five stages × 229 entries) +# Via pytest (parametrized — 1,190 tests: five stages × 238 entries) pytest tests/test_conformance.py -v # Via standalone script (used in CI and pre-commit) @@ -964,9 +964,9 @@ Twenty-nine scripts in `scripts/` validate cross-cutting concerns beyond unit te | Script | What it validates | |--------|-------------------| -| `check_conformance.py` | All 229 conformance entries hold at their declared level (parse/check/verify/run) — positives pass; the negatives fail at the stage their `expected_error_stage` names (`check` by default, or `compile`, which also asserts the program type-checks cleanly) with their `expected_error` E-code | +| `check_conformance.py` | All 238 conformance entries hold at their declared level (parse/check/verify/run) — positives pass; the negatives fail at the stage their `expected_error_stage` names (`check` by default, or `compile`, which also asserts the program type-checks cleanly) with their `expected_error` E-code | | `check_examples.py` | All 42 `.vera` examples pass `vera check` + `vera verify` | -| `check_corpus_canonical.py` | All 278 corpus programs (recursive over `examples/` + `tests/conformance/`) are in canonical form under `vera fmt` | +| `check_corpus_canonical.py` | All 287 corpus programs (recursive over `examples/` + `tests/conformance/`) are in canonical form under `vera fmt` | | `check_examples_readme.py` | Every `vera run` command in examples/README.md references an existing file and exported function | | `check_spec_examples.py` | 189 parseable code blocks from spec chapters: parse, type-check, and verify | | `check_readme_examples.py` | All Vera code blocks in README.md parse correctly | @@ -1073,9 +1073,9 @@ The repository configures 36 hooks across two stages: 34 run at the commit stage | `ruff check .` | Lint Python with ruff (default `F` + `E` rules) | | `mypy vera/` | Type-check compiler in strict mode | | `pytest tests/ -q` | Run full test suite | -| `check_conformance.py` | All 229 conformance entries hold at their declared level — positives pass; negatives fail at the stage their `expected_error_stage` names (`check` or `compile`) with their `expected_error` E-code | +| `check_conformance.py` | All 238 conformance entries hold at their declared level — positives pass; negatives fail at the stage their `expected_error_stage` names (`check` or `compile`) with their `expected_error` E-code | | `check_examples.py` | All 42 examples pass `vera check` + `vera verify` | -| `check_corpus_canonical.py` | All 278 `examples/` + `tests/conformance/` programs (recursive) are in canonical form (`vera fmt`) | +| `check_corpus_canonical.py` | All 287 `examples/` + `tests/conformance/` programs (recursive) are in canonical form (`vera fmt`) | | `check_examples_readme.py` | `vera run` commands in `examples/README.md` reference existing files and exported functions | | `check_readme_examples.py` | README code blocks parse correctly | | `check_examples_doc.py` | EXAMPLES.md code blocks parse correctly | diff --git a/docs/SKILL.md b/docs/SKILL.md index 12aa3f86..358dd597 100644 --- a/docs/SKILL.md +++ b/docs/SKILL.md @@ -1907,7 +1907,7 @@ Imported function contracts are verified at call sites by the SMT solver. Precon Cross-module compilation uses a flattening strategy: imported function bodies are compiled into the same WASM module as the importing program. The result is a single self-contained `.wasm` binary. Imported functions are internal (not exported); only the importing program's `public` functions are WASM exports. -If two of a namespace's imports supply the same bare name, `vera check` refuses it — E155 (function), E156 (data type), E157 (constructor) — reported at the second supplying import, in whichever file holds the clash. For a **function** name, resolve it either by narrowing one import (`import m(other_name);`) so a single supplier remains, or by declaring the name locally and reaching the imports with the module-qualified form (`m::name(...)`). For a **data type** or **constructor** name, rename the declaration in one of the two modules: compilation refuses two modules' same-named data declarations however the importer filters or shadows them, so no import-side change resolves those. The compile-time rails E608/E609/E610 remain as the backstop and refuse a wider set — any two modules in one program, not just two suppliers of one namespace. Local definitions shadow imported names without error. +If two of a namespace's imports supply the same bare name, `vera check` refuses it — E155 (function), E156 (data type), E157 (constructor) — reported at the second supplying import, in whichever file holds the clash. For a **function** name, resolve it either by narrowing one import (`import m(other_name);`) so a single supplier remains, or by declaring the name locally and reaching the imports with the module-qualified form (`m::name(...)`). For a **data type** or **constructor** name, rename the declaration in one of the two modules: compilation refuses two modules' same-named data declarations however the importer filters or shadows them, so no import-side change resolves those. The compile-time rails E608/E609/E610 remain as the backstop and refuse a wider set, reading declarations rather than any namespace's imports: E608 fires when two modules declare the same **function** name at any visibility — the ones that would share the flattened `$name` — unless both are top-level generics whose clones provably live in different module-qualified namespaces; E609 and E610 have no such exception, so any two modules' same-named `data` declarations or constructors collide however they are declared or imported. Local definitions shadow imported names without error. A module's data type may also collide with one the prelude provides (`Option`, `Result`, `Ordering`, `UrlParts`, `Json`, `HtmlNode`, `Request`, `Response`). One name carries one layout in the compiled program, so the two contend when their shapes differ — different constructors, a different constructor order, different field types, or a different number of type parameters (their names are free, since they are matched by position). The compiler then reports **E621** at the module's declaration; rename it there, or give it the prelude's shape. A module declaring `data Json` is legal on its own, because the prelude injects `Json` only when the entry program uses it; `Option`, `Result`, `Ordering` and `UrlParts` are in every program, so a differently-shaped module declaration of one of those always contends. Declaring the type in the **entry** file instead suppresses the prelude's own, so it never contends with the prelude — but it does not settle a clash with a module that also declares the name, which is a separate pair the compiler does not yet arbitrate ([#1312](https://github.com/aallan/vera/issues/1312)). @@ -2395,7 +2395,7 @@ public fn main(@Unit -> @Unit) ## Conformance Suite -The `tests/conformance/` directory contains 229 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. +The `tests/conformance/` directory contains 238 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. Each program is organized by spec chapter (`ch01_int_literals.vera`, `ch04_match_basic.vera`, `ch07_state_handler.vera`, etc.) and the `manifest.json` file maps features to programs. When you need to see how a specific construct works, check the conformance program before reading the spec. diff --git a/docs/llms-full.txt b/docs/llms-full.txt index b22363af..9b0ff15f 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -1913,7 +1913,7 @@ Imported function contracts are verified at call sites by the SMT solver. Precon Cross-module compilation uses a flattening strategy: imported function bodies are compiled into the same WASM module as the importing program. The result is a single self-contained `.wasm` binary. Imported functions are internal (not exported); only the importing program's `public` functions are WASM exports. -If two of a namespace's imports supply the same bare name, `vera check` refuses it — E155 (function), E156 (data type), E157 (constructor) — reported at the second supplying import, in whichever file holds the clash. For a **function** name, resolve it either by narrowing one import (`import m(other_name);`) so a single supplier remains, or by declaring the name locally and reaching the imports with the module-qualified form (`m::name(...)`). For a **data type** or **constructor** name, rename the declaration in one of the two modules: compilation refuses two modules' same-named data declarations however the importer filters or shadows them, so no import-side change resolves those. The compile-time rails E608/E609/E610 remain as the backstop and refuse a wider set — any two modules in one program, not just two suppliers of one namespace. Local definitions shadow imported names without error. +If two of a namespace's imports supply the same bare name, `vera check` refuses it — E155 (function), E156 (data type), E157 (constructor) — reported at the second supplying import, in whichever file holds the clash. For a **function** name, resolve it either by narrowing one import (`import m(other_name);`) so a single supplier remains, or by declaring the name locally and reaching the imports with the module-qualified form (`m::name(...)`). For a **data type** or **constructor** name, rename the declaration in one of the two modules: compilation refuses two modules' same-named data declarations however the importer filters or shadows them, so no import-side change resolves those. The compile-time rails E608/E609/E610 remain as the backstop and refuse a wider set, reading declarations rather than any namespace's imports: E608 fires when two modules declare the same **function** name at any visibility — the ones that would share the flattened `$name` — unless both are top-level generics whose clones provably live in different module-qualified namespaces; E609 and E610 have no such exception, so any two modules' same-named `data` declarations or constructors collide however they are declared or imported. Local definitions shadow imported names without error. A module's data type may also collide with one the prelude provides (`Option`, `Result`, `Ordering`, `UrlParts`, `Json`, `HtmlNode`, `Request`, `Response`). One name carries one layout in the compiled program, so the two contend when their shapes differ — different constructors, a different constructor order, different field types, or a different number of type parameters (their names are free, since they are matched by position). The compiler then reports **E621** at the module's declaration; rename it there, or give it the prelude's shape. A module declaring `data Json` is legal on its own, because the prelude injects `Json` only when the entry program uses it; `Option`, `Result`, `Ordering` and `UrlParts` are in every program, so a differently-shaped module declaration of one of those always contends. Declaring the type in the **entry** file instead suppresses the prelude's own, so it never contends with the prelude — but it does not settle a clash with a module that also declares the name, which is a separate pair the compiler does not yet arbitrate ([#1312](https://github.com/aallan/vera/issues/1312)). @@ -2401,7 +2401,7 @@ public fn main(@Unit -> @Unit) ## Conformance Suite -The `tests/conformance/` directory contains 229 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. +The `tests/conformance/` directory contains 238 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. Each program is organized by spec chapter (`ch01_int_literals.vera`, `ch04_match_basic.vera`, `ch07_state_handler.vera`, etc.) and the `manifest.json` file maps features to programs. When you need to see how a specific construct works, check the conformance program before reading the spec. @@ -2480,7 +2480,7 @@ Read `SKILL.md` for the full language reference. It covers syntax, slot referenc ### Conformance programs as reference -The conformance suite in `tests/conformance/` contains 229 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-seven negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) instead must *fail* with the E-code in their `expected_error` field, at the stage their `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. +The conformance suite in `tests/conformance/` contains 238 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-seven negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) instead must *fail* with the E-code in their `expected_error` field, at the stage their `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. ### Workflow @@ -2659,9 +2659,9 @@ Each stage is a module with a single public API function (`parse_file`, `transfo pytest tests/ -v # Run all tests (see TESTING.md) pytest tests/test_conformance.py -v # Conformance suite only mypy vera/ # Type-check the compiler -python scripts/check_conformance.py # All 229 conformance programs hold (positives pass; negatives fail with their E-code) +python scripts/check_conformance.py # All 238 conformance programs hold (positives pass; negatives fail with their E-code) python scripts/check_examples.py # All 42 examples must pass -python scripts/check_corpus_canonical.py # All 278 corpus programs in canonical form +python scripts/check_corpus_canonical.py # All 287 corpus programs in canonical form ``` Test helpers follow a pattern: `_check_ok(source)` / `_check_err(source, match)` / `_verify_ok(source)` / `_verify_err(source, match)`. See existing tests for examples. @@ -2670,7 +2670,7 @@ When implementing a new language feature, write the conformance program *first* ### Invariants -- All 229 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first +- All 238 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first - All 42 examples in `examples/` must pass `vera check` and `vera verify` - `mypy vera/` must be clean - `pytest tests/ -v` must pass @@ -3215,7 +3215,7 @@ None of this is Vera-specific, but it validates the design choices. The thesis i This is a real concern. LLMs are trained on trillions of tokens of Python, TypeScript, and JavaScript. A MojoBench study (NAACL 2025) found that even fine-tuned models achieved only 30–35% improvement over base models on Mojo code generation, illustrating the cold-start problem for new languages. -Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (229 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification. +Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (238 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification. ## How does Vera compare to Dafny / Lean / Koka / F*? @@ -3258,7 +3258,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 11,134 tests, including a 229-program conformance suite +- 11,237 tests, including a 238-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/spec/08-modules.md b/spec/08-modules.md index b94bd992..170d1b8a 100644 --- a/spec/08-modules.md +++ b/spec/08-modules.md @@ -391,10 +391,12 @@ This is Pass 0 of the three-pass type-checking architecture (see Chapter 5). After module registration, the main type environment contains: - All built-in types and functions. -- All imported `public` functions (with their full signatures and contracts), except a bare function name two imports both supply — that name is refused (§8.5.2.2) and enters no namespace, so a bare call to it resolves to nothing rather than to whichever supplier was injected first. -- All imported `public` data types (with their constructors). +- All imported `public` functions (with their full signatures and contracts). +- All imported `public` data types, with their constructors. - All locally declared types and functions (from Pass 1). +A name two imports both supply is the exception, in every one of those namespaces: it is refused (§8.5.2.2) and enters none of them, so a use of it resolves to nothing rather than to whichever supplier was injected first. That holds for a clashing function name (`E155`), a clashing data type name (`E156`) and a clashing constructor name (`E157`) independently — a type excluded for a clash takes its constructors with it, and a constructor name two differently-named types supply is excluded on its own while both types remain. + Local declarations always take priority over imported declarations due to the `setdefault` injection order: imports are injected first, then local registration overwrites any collisions. ### 8.7.3 Per-Module Dictionaries diff --git a/spec/11-compilation.md b/spec/11-compilation.md index bfbe89a7..d46f0673 100644 --- a/spec/11-compilation.md +++ b/spec/11-compilation.md @@ -615,7 +615,7 @@ The compilation process: Imported functions are **not** exported from the WASM module — only the importing program's `public` functions are exports. -**Name collision detection**: If two imported modules define a function (E608), data type (E609), or constructor (E610) with the same name, the compiler reports an error listing both modules. These rails are the backstop behind the check-phase refusal of §8.5.2.2 (E155/E156/E157), which reports the same shape earlier and in the namespace that holds it; they refuse a wider set, keyed on the declarations rather than on any namespace's imports. For a **function** name the remedies are §8.5.2.2's — a selective import, or a local declaration plus the module-qualified form. For a **data type** or **constructor** name, rename the declaration in one of the source modules: these rails consult neither visibility nor the importer's filter nor local shadowing, so no import-side change resolves them. Qualified calls do not disambiguate a collision and are not a route to doing so — §8.5.2.2 refuses the ambiguity itself, and the forward-compatible alternative it leaves open is defining a resolution order, not mangling the bare name away. +**Name collision detection**: If two imported modules define a function (E608), data type (E609), or constructor (E610) with the same name, the compiler reports an error listing both modules. These rails are the backstop behind the check-phase refusal of §8.5.2.2 (E155/E156/E157), which reports the same shape earlier and in the namespace that holds it. They refuse a wider set, because they read the declarations rather than any namespace's imports: E608 fires for two modules' same-named **function** declarations of any visibility — the ones that would share the flattened `$name` — excepting a pair of top-level generics the ownership classification proves land in distinct clone namespaces (§11.16's qualified-only naming rule), which emit nothing under the bare name and so cannot overwrite each other. E609 and E610 have no such exception: any two modules' same-named `data` declarations, or same-named constructors, collide whatever their visibility. For a **function** name the remedies are §8.5.2.2's — a selective import, or a local declaration plus the module-qualified form. For a **data type** or **constructor** name, rename the declaration in one of the source modules: these rails consult neither visibility nor the importer's filter nor local shadowing, so no import-side change resolves them. Qualified calls do not disambiguate a collision and are not a route to doing so — §8.5.2.2 refuses the ambiguity itself, and the forward-compatible alternative it leaves open is defining a resolution order, not mangling the bare name away. An imported module's data type may collide with one the **prelude** provides in the same way, and the compiler reports **E621** at the module's declaration. The prelude's declarations are compiled into this same flat namespace, which holds one layout per name, so two declarations of a prelude name contend exactly when their *shapes* differ — different constructors, a different constructor order (the tag is the position), or different field types; type parameters are compared by position, so renaming one is not a difference. A module that restates the prelude's type shares the one layout and compiles. The diagnostic names the module and the type and offers both resolutions: rename it in the module, or give it the prelude's shape. diff --git a/tests/test_ambiguous_import_refusal_1304.py b/tests/test_ambiguous_import_refusal_1304.py index 083f5329..6944fc73 100644 --- a/tests/test_ambiguous_import_refusal_1304.py +++ b/tests/test_ambiguous_import_refusal_1304.py @@ -40,7 +40,11 @@ import pytest import vera -from tests.module_fixture_helpers import build_multi_module, module_value +from tests.module_fixture_helpers import ( + build_multi_module, + build_multi_module_past_check, + module_value, +) INT_ANSWER = 111 LOCAL_ANSWER = 222 @@ -139,6 +143,46 @@ def _write(tmp_path: Path, files: dict[str, str]) -> Path: return tmp_path / "main.vera" +def _run_check_json( + argv: list[str], *, seed: str, +) -> subprocess.CompletedProcess[str]: + """One ``vera check --json`` subprocess; the raw result, unparsed.""" + return subprocess.run( + argv, + capture_output=True, + text=True, + encoding="utf-8", + env={**os.environ, "PYTHONHASHSEED": seed, + "PYTHONPATH": str(_VERA_ROOT)}, + check=False, + ) + + +def _parse_check_json( + result: subprocess.CompletedProcess[str], *, seed: str, +) -> dict: + """The subprocess's stdout as JSON, or a failure that says why. + + ``check=False`` plus a bare ``json.loads`` would turn a crashed CLI into a + ``JSONDecodeError`` about column 1 of an empty document, with the exit + code and the whole traceback on stderr discarded — and a crash that + happens under SOME hash seeds is precisely what this file exists to + catch, so the one failure mode the suite must describe well is the one it + described worst. The seed, the exit code and both streams travel with + the failure instead. + """ + try: + payload: dict = json.loads(result.stdout) + except json.JSONDecodeError as exc: + raise AssertionError( + f"vera check --json produced no parseable JSON under " + f"PYTHONHASHSEED={seed} (exit code {result.returncode}): {exc}\n" + f"--- stdout ---\n{result.stdout or ''}\n" + f"--- stderr ---\n{result.stderr or ''}" + ) from exc + return payload + + def _check_json(main_path: Path, *, seed: str) -> dict: """``vera check --json`` in a fresh interpreter under *seed*. @@ -147,17 +191,11 @@ def _check_json(main_path: Path, *, seed: str) -> dict: flap rode is not reachable from inside one process, so an in-process loop would measure one seed many times and call it determinism. """ - result = subprocess.run( + result = _run_check_json( [sys.executable, "-m", "vera.cli", "check", "--json", str(main_path)], - capture_output=True, - text=True, - encoding="utf-8", - env={**os.environ, "PYTHONHASHSEED": seed, - "PYTHONPATH": str(_VERA_ROOT)}, - check=False, + seed=seed, ) - payload: dict = json.loads(result.stdout) - return payload + return _parse_check_json(result, seed=seed) def _codes(payload: dict) -> list[str]: @@ -284,6 +322,29 @@ def test_the_subprocesses_measure_this_checkout() -> None: } +def test_an_unparseable_check_reports_its_stderr_and_exit_code() -> None: + """The helper's own failure path, exercised (#1304 review). + + Pointed at a command that exits nonzero with empty stdout and a known + marker on stderr — the shape a seed-specific CLI crash takes. Before + this, that arrived as a bare ``JSONDecodeError`` about an empty document + with the traceback thrown away, which is the least useful possible + report of the one failure the determinism cells exist to find. + """ + marker = "vera-cli-crashed-under-this-seed" + result = _run_check_json( + [sys.executable, "-c", + f"import sys; sys.stderr.write({marker!r}); sys.exit(3)"], + seed="0", + ) + with pytest.raises(AssertionError) as caught: + _parse_check_json(result, seed="0") + message = str(caught.value) + assert marker in message, message + assert "exit code 3" in message, message + assert "PYTHONHASHSEED=0" in message, message + + class TestTheDataSideFlapShapes: """The same defect in the TYPE and CONSTRUCTOR namespaces (#1304). @@ -384,7 +445,32 @@ def test_renaming_is_the_remedy_the_diagnostic_offers( assert "Rename" in fix assert "does not resolve it" in fix - @pytest.mark.parametrize("remedy", ["selective", "local"]) + def test_a_shared_constructor_is_backstopped_by_E610( + self, tmp_path: Path, + ) -> None: + """The E610 axis, pinned at both layers (#1317 evidence). + + Two DIFFERENTLY-named types sharing one constructor: the checker + refuses `Sq` (E157) and codegen's constructor rail refuses the pair + (E610), so the sibling of the E609 cell above is measured rather + than assumed. It is the shape that shows the collision is not about + the type name — `Alpha` and `Beta` never clash — which is why the + two codes are separate on both sides. + """ + files = { + "liba.vera": _ADT_A.replace("data Shape", "data Alpha"), + "libb.vera": _ADT_B.replace("data Shape", "data Beta"), + "midc.vera": _ADT_MID, + "main.vera": _ADT_MAIN, + } + check_errors, _result, cg_errors = build_multi_module_past_check( + tmp_path, files, + ) + assert [c for c, _ in check_errors if c == "E157"], check_errors + assert not [c for c, _ in check_errors if c == "E156"], check_errors + assert [c for c, _ in cg_errors if c == "E610"], cg_errors + + @pytest.mark.parametrize("remedy", ["selective", "local", "private"]) def test_the_function_remedies_do_not_clear_a_data_clash( self, tmp_path: Path, remedy: str, ) -> None: @@ -397,7 +483,9 @@ def test_the_function_remedies_do_not_clear_a_data_clash( the signal to revisit the data-side fix text. """ files = dict(_ADT_FILES["ab"]) - if remedy == "selective": + if remedy == "private": + files["libb.vera"] = _ADT_B.replace("public data", "private data") + elif remedy == "selective": files["libb.vera"] = _ADT_B + """ public fn helper(@Unit -> @Int) requires(true) diff --git a/vera/README.md b/vera/README.md index db0eb308..3cc60f4e 100644 --- a/vera/README.md +++ b/vera/README.md @@ -752,7 +752,7 @@ The `ERROR_CODES` dict in `errors.py` maps every code to a short description (16 ## Test Suite -Testing spans a **pytest suite** of 11,134 tests across 169 files — compiler-internals unit tests plus a **conformance suite** (229 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. +Testing spans a **pytest suite** of 11,237 tests across 170 files — compiler-internals unit tests plus a **conformance suite** (238 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. See **[TESTING.md](../TESTING.md)** for the comprehensive testing reference -- test file table, conformance suite details, compiler code coverage, language feature coverage, helper conventions, validation scripts, CI pipeline, and guidelines for adding tests. From 7a314b5fd763937bace4f1413ba69ca0836f1396 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 16:24:20 +0100 Subject: [PATCH 39/69] Close CodeRabbit wave 2: a conformance pair that could half-rot, and four smaller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The ADT twin pinned one code per order, so half a regression was invisible.** `ch08_ambiguous_import_adt_rejected` pinned `E156` and its swapped sibling pinned `E157`, which reads as covering both codes and does not: the runner asserts only the one code its entry names, so a change that dropped `E156` while leaving `E157` left the swapped entry green. Measured — with the `E156` row deleted from the checker's emission table, the swapped fixture still reports `['E130', 'E157', 'E313']`, which the old pinning accepts and the new one refuses. Both entries now pin `E156`, matching the function pair (both `E155`), and the fixture comment says why the manifest names one code where the program raises two. That both codes fire in both orders stays asserted, by the unit cells that check the whole diagnostic set. **SKILL's local-shadowing sentence was true of functions only.** "Local definitions shadow imported names without error" is checker resolution, and `E609`/`E610` reject two modules' same-named data types or constructors at compile whatever the importer declares — so a local `data` of the same name does not clear them. The sentence now says which half it governs. **Spec §8.3.2's design note pointed data collisions at selective import.** Same correction one layer up: both mechanisms it describes — the selective import and the local definition that shadows — address function names, and a clashing data type or constructor is renamed in a source module instead (§8.5.2.2, §11.16). **README's project-status line said 229 conformance programs**, against a manifest holding 238. That figure sits outside the doc-counts oracle's patterns, which is why the gate stayed green over it; verified against the manifest rather than against another document. **`prelude_adt_names()` rebuilt its frozenset per namespace.** Its consumer, `_adt_members_in_scope`, runs once per namespace, and the answer is a constant — the prelude blocks are. Now `lru_cache`d exactly as `prelude_data_decls()` beneath it, which it derives from. One finding is deliberately not acted on: `vera/_since.py` records `E155`, `E156` and `E157` as `0.1.12` while `[project].version` still reads `0.1.11`. That is the convention rather than a mismatch — a new code carries the version it SHIPS in, and the release PR this sprint cuts `0.1.12` and bumps the version surface then. `E621`, already merged on this release branch, carries `0.1.12` identically. Co-Authored-By: Claude --- README.md | 2 +- SKILL.md | 2 +- docs/SKILL.md | 2 +- docs/llms-full.txt | 2 +- spec/08-modules.md | 2 +- .../ch08_ambiguous_import_adt_swapped_rejected.vera | 6 +++++- tests/conformance/manifest.json | 2 +- vera/prelude.py | 5 +++++ 8 files changed, 16 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 86b1f915..8c2f846c 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ cp /path/to/vera/SKILL.md ~/.claude/skills/vera-language/SKILL.md ## Project status -Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 11,237 tests, 95% Python code coverage, 229 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. +Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 11,237 tests, 95% Python code coverage, 238 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. The reference compiler — parser, AST, type checker, contract verifier (Z3), WASM code generator, module system, browser runtime, and runtime contract insertion — is working. The language specification is in draft across [14 chapters](spec/). diff --git a/SKILL.md b/SKILL.md index 05be366f..5310f12b 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1951,7 +1951,7 @@ Imported function contracts are verified at call sites by the SMT solver. Precon Cross-module compilation uses a flattening strategy: imported function bodies are compiled into the same WASM module as the importing program. The result is a single self-contained `.wasm` binary. Imported functions are internal (not exported); only the importing program's `public` functions are WASM exports. -If two of a namespace's imports supply the same bare name, `vera check` refuses it — E155 (function), E156 (data type), E157 (constructor) — reported at the second supplying import, in whichever file holds the clash. For a **function** name, resolve it either by narrowing one import (`import m(other_name);`) so a single supplier remains, or by declaring the name locally and reaching the imports with the module-qualified form (`m::name(...)`). For a **data type** or **constructor** name, rename the declaration in one of the two modules: compilation refuses two modules' same-named data declarations however the importer filters or shadows them, so no import-side change resolves those. The compile-time rails E608/E609/E610 remain as the backstop and refuse a wider set, reading declarations rather than any namespace's imports: E608 fires when two modules declare the same **function** name at any visibility — the ones that would share the flattened `$name` — unless both are top-level generics whose clones provably live in different module-qualified namespaces; E609 and E610 have no such exception, so any two modules' same-named `data` declarations or constructors collide however they are declared or imported. Local definitions shadow imported names without error. +If two of a namespace's imports supply the same bare name, `vera check` refuses it — E155 (function), E156 (data type), E157 (constructor) — reported at the second supplying import, in whichever file holds the clash. For a **function** name, resolve it either by narrowing one import (`import m(other_name);`) so a single supplier remains, or by declaring the name locally and reaching the imports with the module-qualified form (`m::name(...)`). For a **data type** or **constructor** name, rename the declaration in one of the two modules: compilation refuses two modules' same-named data declarations however the importer filters or shadows them, so no import-side change resolves those. The compile-time rails E608/E609/E610 remain as the backstop and refuse a wider set, reading declarations rather than any namespace's imports: E608 fires when two modules declare the same **function** name at any visibility — the ones that would share the flattened `$name` — unless both are top-level generics whose clones provably live in different module-qualified namespaces; E609 and E610 have no such exception, so any two modules' same-named `data` declarations or constructors collide however they are declared or imported. A local definition shadows an imported **function** name without error — that is checker resolution, and the bare call becomes the local one. It does not extend to data: E609/E610 reject two modules' same-named data types or constructors at compile whatever the importer declares or imports, so a local `data` of the same name does not clear them. A module's data type may also collide with one the prelude provides (`Option`, `Result`, `Ordering`, `UrlParts`, `Json`, `HtmlNode`, `Request`, `Response`). One name carries one layout in the compiled program, so the two contend when their shapes differ — different constructors, a different constructor order, different field types, or a different number of type parameters (their names are free, since they are matched by position). The compiler then reports **E621** at the module's declaration; rename it there, or give it the prelude's shape. A module declaring `data Json` is legal on its own, because the prelude injects `Json` only when the entry program uses it; `Option`, `Result`, `Ordering` and `UrlParts` are in every program, so a differently-shaped module declaration of one of those always contends. Declaring the type in the **entry** file instead suppresses the prelude's own, so it never contends with the prelude — but it does not settle a clash with a module that also declares the name, which is a separate pair the compiler does not yet arbitrate ([#1312](https://github.com/aallan/vera/issues/1312)). diff --git a/docs/SKILL.md b/docs/SKILL.md index 358dd597..0549310d 100644 --- a/docs/SKILL.md +++ b/docs/SKILL.md @@ -1907,7 +1907,7 @@ Imported function contracts are verified at call sites by the SMT solver. Precon Cross-module compilation uses a flattening strategy: imported function bodies are compiled into the same WASM module as the importing program. The result is a single self-contained `.wasm` binary. Imported functions are internal (not exported); only the importing program's `public` functions are WASM exports. -If two of a namespace's imports supply the same bare name, `vera check` refuses it — E155 (function), E156 (data type), E157 (constructor) — reported at the second supplying import, in whichever file holds the clash. For a **function** name, resolve it either by narrowing one import (`import m(other_name);`) so a single supplier remains, or by declaring the name locally and reaching the imports with the module-qualified form (`m::name(...)`). For a **data type** or **constructor** name, rename the declaration in one of the two modules: compilation refuses two modules' same-named data declarations however the importer filters or shadows them, so no import-side change resolves those. The compile-time rails E608/E609/E610 remain as the backstop and refuse a wider set, reading declarations rather than any namespace's imports: E608 fires when two modules declare the same **function** name at any visibility — the ones that would share the flattened `$name` — unless both are top-level generics whose clones provably live in different module-qualified namespaces; E609 and E610 have no such exception, so any two modules' same-named `data` declarations or constructors collide however they are declared or imported. Local definitions shadow imported names without error. +If two of a namespace's imports supply the same bare name, `vera check` refuses it — E155 (function), E156 (data type), E157 (constructor) — reported at the second supplying import, in whichever file holds the clash. For a **function** name, resolve it either by narrowing one import (`import m(other_name);`) so a single supplier remains, or by declaring the name locally and reaching the imports with the module-qualified form (`m::name(...)`). For a **data type** or **constructor** name, rename the declaration in one of the two modules: compilation refuses two modules' same-named data declarations however the importer filters or shadows them, so no import-side change resolves those. The compile-time rails E608/E609/E610 remain as the backstop and refuse a wider set, reading declarations rather than any namespace's imports: E608 fires when two modules declare the same **function** name at any visibility — the ones that would share the flattened `$name` — unless both are top-level generics whose clones provably live in different module-qualified namespaces; E609 and E610 have no such exception, so any two modules' same-named `data` declarations or constructors collide however they are declared or imported. A local definition shadows an imported **function** name without error — that is checker resolution, and the bare call becomes the local one. It does not extend to data: E609/E610 reject two modules' same-named data types or constructors at compile whatever the importer declares or imports, so a local `data` of the same name does not clear them. A module's data type may also collide with one the prelude provides (`Option`, `Result`, `Ordering`, `UrlParts`, `Json`, `HtmlNode`, `Request`, `Response`). One name carries one layout in the compiled program, so the two contend when their shapes differ — different constructors, a different constructor order, different field types, or a different number of type parameters (their names are free, since they are matched by position). The compiler then reports **E621** at the module's declaration; rename it there, or give it the prelude's shape. A module declaring `data Json` is legal on its own, because the prelude injects `Json` only when the entry program uses it; `Option`, `Result`, `Ordering` and `UrlParts` are in every program, so a differently-shaped module declaration of one of those always contends. Declaring the type in the **entry** file instead suppresses the prelude's own, so it never contends with the prelude — but it does not settle a clash with a module that also declares the name, which is a separate pair the compiler does not yet arbitrate ([#1312](https://github.com/aallan/vera/issues/1312)). diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 9b0ff15f..80407308 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -1913,7 +1913,7 @@ Imported function contracts are verified at call sites by the SMT solver. Precon Cross-module compilation uses a flattening strategy: imported function bodies are compiled into the same WASM module as the importing program. The result is a single self-contained `.wasm` binary. Imported functions are internal (not exported); only the importing program's `public` functions are WASM exports. -If two of a namespace's imports supply the same bare name, `vera check` refuses it — E155 (function), E156 (data type), E157 (constructor) — reported at the second supplying import, in whichever file holds the clash. For a **function** name, resolve it either by narrowing one import (`import m(other_name);`) so a single supplier remains, or by declaring the name locally and reaching the imports with the module-qualified form (`m::name(...)`). For a **data type** or **constructor** name, rename the declaration in one of the two modules: compilation refuses two modules' same-named data declarations however the importer filters or shadows them, so no import-side change resolves those. The compile-time rails E608/E609/E610 remain as the backstop and refuse a wider set, reading declarations rather than any namespace's imports: E608 fires when two modules declare the same **function** name at any visibility — the ones that would share the flattened `$name` — unless both are top-level generics whose clones provably live in different module-qualified namespaces; E609 and E610 have no such exception, so any two modules' same-named `data` declarations or constructors collide however they are declared or imported. Local definitions shadow imported names without error. +If two of a namespace's imports supply the same bare name, `vera check` refuses it — E155 (function), E156 (data type), E157 (constructor) — reported at the second supplying import, in whichever file holds the clash. For a **function** name, resolve it either by narrowing one import (`import m(other_name);`) so a single supplier remains, or by declaring the name locally and reaching the imports with the module-qualified form (`m::name(...)`). For a **data type** or **constructor** name, rename the declaration in one of the two modules: compilation refuses two modules' same-named data declarations however the importer filters or shadows them, so no import-side change resolves those. The compile-time rails E608/E609/E610 remain as the backstop and refuse a wider set, reading declarations rather than any namespace's imports: E608 fires when two modules declare the same **function** name at any visibility — the ones that would share the flattened `$name` — unless both are top-level generics whose clones provably live in different module-qualified namespaces; E609 and E610 have no such exception, so any two modules' same-named `data` declarations or constructors collide however they are declared or imported. A local definition shadows an imported **function** name without error — that is checker resolution, and the bare call becomes the local one. It does not extend to data: E609/E610 reject two modules' same-named data types or constructors at compile whatever the importer declares or imports, so a local `data` of the same name does not clear them. A module's data type may also collide with one the prelude provides (`Option`, `Result`, `Ordering`, `UrlParts`, `Json`, `HtmlNode`, `Request`, `Response`). One name carries one layout in the compiled program, so the two contend when their shapes differ — different constructors, a different constructor order, different field types, or a different number of type parameters (their names are free, since they are matched by position). The compiler then reports **E621** at the module's declaration; rename it there, or give it the prelude's shape. A module declaring `data Json` is legal on its own, because the prelude injects `Json` only when the entry program uses it; `Option`, `Result`, `Ordering` and `UrlParts` are in every program, so a differently-shaped module declaration of one of those always contends. Declaring the type in the **entry** file instead suppresses the prelude's own, so it never contends with the prelude — but it does not settle a clash with a module that also declares the name, which is a separate pair the compiler does not yet arbitrate ([#1312](https://github.com/aallan/vera/issues/1312)). diff --git a/spec/08-modules.md b/spec/08-modules.md index 170d1b8a..5b066aaa 100644 --- a/spec/08-modules.md +++ b/spec/08-modules.md @@ -64,7 +64,7 @@ A selective import makes only the named declarations available. Each name in the Error: Cannot import 'helper' from module 'vera.math': it is private. ``` -**Design note.** Vera does not support wildcard exclusion syntax (e.g., `import m hiding(x)`). When a module exports names that conflict with local definitions or other imports, the canonical mechanism is selective import: list exactly the names needed — advice for the local-definition case (§8.5.2), and a requirement for the two-import case (§8.5.2.2). Wildcard exclusion would be a semantic equivalent of selective import — the same import set expressible two ways — violating the one-canonical-form principle (§0.2.3). When wildcard import causes a name clash, the local definition shadows the import (§8.5.2), and the imported version remains accessible via module-qualified call syntax (§8.5.3). +**Design note.** Vera does not support wildcard exclusion syntax (e.g., `import m hiding(x)`). When a module exports names that conflict with local definitions or other imports, the canonical mechanism is selective import: list exactly the names needed — advice for the local-definition case (§8.5.2), and a requirement for the two-import case (§8.5.2.2). Wildcard exclusion would be a semantic equivalent of selective import — the same import set expressible two ways — violating the one-canonical-form principle (§0.2.3). When wildcard import causes a name clash, the local definition shadows the import (§8.5.2), and the imported version remains accessible via module-qualified call syntax (§8.5.3). Both mechanisms address **function** names. A clashing data type or constructor name is not resolved by either: the flat compilation strategy refuses two modules' same-named data declarations however the importer filters or shadows them (§11.16), so the remedy there is to rename the declaration in one of the source modules (§8.5.2.2). ### 8.3.3 Grammar diff --git a/tests/conformance/ch08_ambiguous_import_adt_swapped_rejected.vera b/tests/conformance/ch08_ambiguous_import_adt_swapped_rejected.vera index 7674e0ce..783da564 100644 --- a/tests/conformance/ch08_ambiguous_import_adt_swapped_rejected.vera +++ b/tests/conformance/ch08_ambiguous_import_adt_swapped_rejected.vera @@ -1,7 +1,11 @@ -- Conformance: the same data refusal with the imports written the other way (Chapter 8, #1304) -- Tests: [E156] and [E157] again. The verdict is a property of the import -- SET, not of which import is written first: an order-sensitive rule would --- accept one of these two spellings and reject the other. +-- accept one of these two spellings and reject the other. The manifest pins +-- [E156] on BOTH orders rather than one code each -- pinning a different code +-- per order would let a regression that dropped [E156] in this order alone go +-- unnoticed. Both codes firing in both orders is asserted by the unit cells +-- in tests/test_ambiguous_import_refusal_1304.py. import ch08_ambiguous_import_adt_lib_bool; import ch08_ambiguous_import_adt_lib_int; diff --git a/tests/conformance/manifest.json b/tests/conformance/manifest.json index c39bf4f9..7a17a1b4 100644 --- a/tests/conformance/manifest.json +++ b/tests/conformance/manifest.json @@ -3407,7 +3407,7 @@ "title": "The same data refusal with the imports written the other way", "level": "check", "spec_ref": "Section 8.5.2.2", - "expected_error": "E157", + "expected_error": "E156", "features": [ "ambiguous_import", "adt", diff --git a/vera/prelude.py b/vera/prelude.py index 2afc0153..4113e6a2 100644 --- a/vera/prelude.py +++ b/vera/prelude.py @@ -810,6 +810,7 @@ def prelude_data_decls() -> Mapping[str, ast.DataDecl]: }) +@functools.lru_cache(maxsize=1) def prelude_adt_names() -> frozenset[str]: """Every ADT name the prelude can provide (#1277). @@ -829,6 +830,10 @@ def prelude_adt_names() -> frozenset[str]: themselves — see ``_adt_members_in_scope`` for why naming an undemanded ADT here is inert (a property of today's consumer, not of the set) and why the contended case is refused rather than resolved. + Cached like :func:`prelude_data_decls` beneath it, and for the same + reason: the blocks are constants, so the answer is too. Its consumer + is ``_adt_members_in_scope``, which runs once per namespace, and + rebuilding an identical frozenset per namespace bought nothing. """ return frozenset(prelude_data_decls()) From 790ea85435eccf1e64294ab763e85cbf88272c50 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 12:26:35 +0100 Subject: [PATCH 40/69] Give json_parse one accept domain, and host failures a Vera error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three rulings in the JSON host boundary, shipping together because they are one story: what `json_parse` takes, and what a program sees when a host binding refuses. Spec §9.7.1 now states the accepted domain instead of leaving each host to inherit its parser's — RFC 8259-valid text that decodes to finite numbers and strings of Unicode scalar values, everything else `Err` at the parse with the same message on every runtime. A non-finite number has three entry routes and the domain closes all of them. The JavaScript constants `NaN`, `Infinity` and `-Infinity` were admitted by Python's default `parse_constant` and refused by `JSON.parse`, so the hosts disagreed about which call rejects a non-finite value. A number that merely overflows — `1e999` — is syntactically valid RFC 8259 that *both* parsers accept, decoding to an infinite `JNumber` that then died at `json_stringify`; that route diverged from the stated domain on both hosts at once, which is the harder failure to notice because nothing disagreed. The same overflow in its integer spelling — `1` followed by 309 zeros — was reference-host-only: `json.loads` returns a Python `int` for a digit string with no fraction and no exponent, so a float range check never saw it, and it died at the WASM boundary with `int too large to convert to float` where `JSON.parse`, which has no int/float split, had returned the shared sentence all along. RFC 8259 §6 sets no limit on a number's range and says an implementation may set one; Vera's is the finite `Float64` values, which is exactly what `json_stringify` can write back. The integer bound is the double rounding boundary rather than `sys.float_info.max`, compared in integer arithmetic: an integer larger than the largest finite double still rounds to it and both hosts accept it, so the obvious bound would have traded one divergence for its mirror image, and a bound implemented as `float(value)` would be the very overflow it is looking for. Underflow is a different question and is not refused: `1e-999` decodes to `0`, finite and in the domain. A lone-surrogate escape was accepted by both parsers too, and then decided at the memory boundary by accident: `TextEncoder` substituted U+FFFD in the browser, `.encode("utf-8")` raised in the reference host. It joins overflow in one document-order walk that returns the sentence itself, so "whichever comes first names the refusal" is the rule rather than a precedence table the two hosts could implement differently. The reference host's `parse_constant` hook records rather than raises, and the refusal is decided after the parse finishes. Raising on sight would answer a different question from the browser's — Python's scanner calls the hook the moment it sees the token, so `[Infinity_x]` would report the non-finite sentence natively while the browser reported a syntax error. Recording and continuing asks what the browser asks, by substituting `0` for each bare constant and re-parsing: would this text be valid JSON if the constants were admitted? The browser's scan also only considers a token where a value may begin, or `-NaN` becomes `-0`, re-parses, and manufactures a refusal the reference host never makes. The surrogate scans differ where the representations do. `json.loads` has already combined a well-formed escape pair into one astral code point, so a plain D800-DFFF range test is complete on the reference host; a JS string is UTF-16, so its scan consumes pairs before judging anything lone. A matched pair still parses on both. `execute()` classified an escaping exception by its type name, so a host import raising an ordinary Python exception skipped the conversion and reached the user as a 63-line interpreter traceback with the captured streams dropped, and in `--json` mode with no envelope at all. The conversion is now keyed on the boundary: everything escaping the guest invocation is either a wasmtime trap or a host callback that raised, and both become a `WasmTrapError`. The taxonomy gains a `host_error` kind whose `Fix` paragraph is empty for the same reason `contract_violation`'s is — the host's own sentence already carries the instruction. This holds the invariant written on `host_print`: a user-level program must never produce a Python traceback regardless of what it does. Since that costs the Python frames a binding bug is diagnosed from, `VERA_DEBUG_HOST_ERRORS=1` re-raises the original untouched. `vera serve` gains an answered request from the same change; before it, a host callback's exception escaped the handler and the connection went unanswered. Closes #1306 Closes #1308 Closes #1302 Co-Authored-By: Claude --- CHANGELOG.md | 6 + CLAUDE.md | 1 + CONTRIBUTING.md | 2 +- ENVIRONMENT.md | 15 + FAQ.md | 2 +- KNOWN_ISSUES.md | 3 - SKILL.md | 9 +- TESTING.md | 8 +- docs/SKILL.md | 9 +- docs/index.html | 2 +- docs/index.md | 2 +- docs/llms-full.txt | 11 +- examples/json.vera | 6 + scripts/build_site.py | 2 +- spec/09-standard-library.md | 9 +- spec/12-runtime.md | 4 +- .../conformance/ch09_json_accept_domain.vera | 226 ++++++ tests/conformance/manifest.json | 18 + tests/test_browser.py | 402 +++++++++- tests/test_json_accept_domain_1306_1308.py | 742 ++++++++++++++++++ tests/test_runtime_traps.py | 281 ++++++- vera/README.md | 8 +- vera/browser/runtime.mjs | 217 +++++ vera/codegen/api.py | 62 +- vera/runtime/json.py | 65 +- vera/runtime/server.py | 8 +- vera/runtime/traps.py | 41 + vera/wasm/json_serde.py | 211 +++++ 28 files changed, 2323 insertions(+), 49 deletions(-) create mode 100644 tests/conformance/ch09_json_accept_domain.vera create mode 100644 tests/test_json_accept_domain_1306_1308.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e34b5c82..01cb90cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Two imports supplying one bare name are refused, in every namespace** ([#1304](https://github.com/aallan/vera/issues/1304)). Spec §8.5 ordered a local declaration against an import (§8.5.2) and gave the module-qualified form for reaching what a clash hides (§8.5.3), but defined no order between two *imports* that both supply one name. Neither did the implementation, and the gap was reachable: a module importing two dependencies that each export `forall fn gen` — one returning `@Int`, one `@Bool` — bound its bare call to whichever supplier a set of module paths happened to yield first, so one unchanged file was `vera check`-green on one run and `[E121] body has type Bool` on the next. Measured at the branch point across eight consecutive runs and eight hash seeds: accepted on seeds 0, 2 and 3, rejected on 1, 4, 5, 6 and 7, with the winner tracking module-name hash order rather than which import is written first. Codegen's E608 rail caught the *entry-visible* pair before it could matter there; the flap lived in the shapes the rail only reached at compile, from inside a module the entry program merely imports. Spec §8.5.2.2 now states the rule — a program **MUST NOT** leave a namespace with two imports supplying one bare function name — and the checker enforces it as **E155**, a check-phase code for a scope question that had been enforced by a codegen rail at the wrong layer. Refusing is what removes the flap rather than merely labelling it: with no pick to make, there is no iteration order left to expose, which a deterministic first-wins order would not have achieved (it would make the resolved declaration implicit in import sequence, §0.2.2, and let a library *adding* an export silently rebind a downstream bare call). The refusal is **definition-gated**, matching the rail it generalises: it fires because the import pair exists, not because a body names it, so an entry program importing two suppliers and never calling either is refused exactly as E608 already refused it, and rewriting a bare call in module-qualified form does not lift it. Two shapes clear it, both exercised through to their runtime value: a **local declaration** of the name (§8.5.2 — every bare call is then the local one, and each import stays reachable through `dep::name(...)`), or a **selective import** narrowing the other module's list. The ambiguity predicate is the one `namespace_fn_names` already derived for #1281 and #1299, now exposed per namespace as well as unioned, so the layer that refuses early and the layer that backstops it cannot disagree about which shape is ambiguous; the E608 condition keeps its cell, driven through a door that bypasses the checker. An ambiguous name is not injected into the type environment at all — reporting the clash while binding one supplier would leave the follow-on diagnostics keyed to whichever module the injection loop reached first, which is the nondeterminism the refusal exists to remove — so a bare call to it misses with an ordinary `E200` instead — an E-coded diagnostic emitted at **warning** severity, which the `--json` envelope reports in `warnings` rather than `diagnostics` and which does not fail the check on its own (measured: a program whose only diagnostic is `E200` reports `ok: true` and exits 0). The W-series is the separate `W001`/`W002` code namespace, and this is not one of them. **The data namespaces flapped the same way and are folded in.** Spec §8.5.4 gives constructor names the same shadowing rules as function names, which a function-only refusal would have made false: two modules each exporting a `public data Shape` with different constructor field types type-checked on some hash seeds and reported `[E213]` on others (accepted on seeds 2, 8, 9, 10 and 11; rejected on 0, 1, 3, 4, 5, 6 and 7), and the accepting seeds were the worse half — `check` **and** `verify` both passed, and the program died at `run` with an `E609` located at line 0 of the entry file, naming two modules the entry never imported. Data types are now **E156** and constructors **E157**, one code per declaration namespace exactly as codegen splits E608/E609/E610, and reported independently because they come apart: two modules exporting differently-named types that share a constructor name clash on the constructor alone. Their remedy differs from the function one and says so — E609/E610 refuse two modules' same-named data declarations by DECLARATION, consulting neither visibility nor the importer's filter nor local shadowing (the relaxation E608 received in [#1281](https://github.com/aallan/vera/issues/1281) has no data-side twin), so narrowing an import or shadowing the name locally leaves the program `E609` at compile. Both were measured against the fixture and both fail — as does marking one declaration `private` — so the two diagnostics prescribe renaming, and a cell pins that measurement so the fix text cannot drift into offering remedies that do not work. That rail over-breadth is now tracked as [#1317](https://github.com/aallan/vera/issues/1317). **A name the built-in registry already owns is not a clash** — the injection loops are `setdefault` over a `TypeEnv` the built-ins populate first, so a dependency exporting its own `option_map` never wins the bare name (measured as `E201` against the *prelude's* two-argument signature). The first cut of this refusal did not pass the built-in snapshot and reported two such dependencies as a clash, which was a new rejection rather than an earlier one; `namespace_fn_names`' claim that its ambiguity half is identical with or without the prelude argument was wrong for the same reason and is corrected, with the codegen call ordering it depends on now pinned by a cell. +- **`json_parse` accepts one domain, and both runtimes accept it** ([#1306](https://github.com/aallan/vera/issues/1306)). The reference host parsed with `json.loads`, whose default `parse_constant` admits `NaN`, `Infinity` and `-Infinity`; the browser gated with `JSON.parse`, which refuses them as RFC 8259 requires. So the two hosts disagreed about *which call* rejects a non-finite value: the browser at the parse with a handled `Err`, the reference host at `json_stringify` — and there as a raw Python traceback rather than a Vera error ([#1302](https://github.com/aallan/vera/issues/1302) below). Spec §9.7.1 now states the accepted domain instead of leaving each host to inherit its parser's: RFC 8259-valid text that decodes to finite numbers and strings of Unicode scalar values, everything else `Err` at the parse with the same message on every runtime. **A non-finite number has two entry routes and the domain closes both.** The constants are one; the other is a syntactically valid number that overflows — `1e999`, `-1e999`, `[1e999]`, `{"a":1e309}` — which both host parsers accept, decoding to an infinite `JNumber` that then died at `json_stringify`, the same divergent-refusal-point defect one syntax over. RFC 8259 §6 sets no limit on a number's range and says an implementation may set one; Vera's is the finite `Float64` values, which is exactly what `json_stringify` can write back. **The refusal covers the integer spelling too**, and that half was reference-host-only: `json.loads` returns a Python `int` for a digit string with no fraction and no exponent, so `1` followed by 309 zeros never met a float range check — and then had to become an f64 at the WASM boundary, where `float()` raises. It died with `int too large to convert to float` where `JSON.parse`, which has no int/float split and sees an `Infinity` either way, returned the shared sentence. The integer bound is the double **rounding** boundary (`2**1024 - 2**970`) rather than `sys.float_info.max`, and compared in integer arithmetic: an integer *larger* than the largest finite double still rounds to it and both hosts accept it, so the obvious bound would have traded this divergence for its mirror image, and a bound implemented as `float(value)` would be the very overflow it is looking for. Underflow is not the same question and is not refused: `1e-999` decodes to `0`, finite and in the domain, pinned as a control beside `1e308` and the largest representable double so a refusal cannot generalise from "unrepresentable magnitude" to "large". Both value-level exclusions — overflow and lone surrogate — are found by ONE document-order walk returning the sentence itself, so "whichever comes first names the refusal" is the rule rather than a precedence table the two hosts could implement differently. The domain is the parse-side counterpart of the canonical output form [#1293](https://github.com/aallan/vera/issues/1293) pinned — a non-finite number has no JSON representation in either direction — and with no entry route through `json_parse`, the output-side refusal is now reachable only from a `JNumber` a program *constructed* from `nan()` or `infinity()`. The reference host's `parse_constant` hook **records rather than raises**, and the refusal is decided after the parse completes. Raising on sight would have made it answer a different question from the browser's: Python's scanner calls the hook the moment it sees the token, so `[Infinity_x]` — malformed for a reason that has nothing to do with the constant — would have reported the non-finite sentence natively while the browser reported a syntax error. Recording and continuing asks what the browser asks, by substituting `0` for each bare constant and re-parsing: *would this text be valid JSON if the constants were admitted?* Only then is the constant the whole story, and only then do both hosts say the same sentence; text malformed for any other reason keeps its host-native syntax message, as every syntax error always has. The browser's scan also only considers a token where a *value* may begin — the start of the text, or after `[`, `,` or `:`. Without that it found `NaN` at offset 1 of `-NaN`, substituted, re-parsed `-0` successfully and reported the shared sentence, where the reference host's parser never reaches the token at all and gives a syntax error. `-NaN`, `[-NaN]`, `+Infinity`, `infinity`, `nan`, `NaNx` and `-Infinityx` are all pinned as host-native on both hosts. The parity battery pins all four probe inputs from the issue's table plus the container and multi-constant shapes, compares the whole `Err` message across hosts rather than which arm was taken, and runs beside controls the refusal must not disturb — `"NaN"` as an ordinary string value among them. + +- **A lone-surrogate escape is refused at the parse, on both runtimes** ([#1308](https://github.com/aallan/vera/issues/1308)). `{"k":"a\ud800b"}` is grammatically legal RFC 8259 whose decoded value is not a sequence of Unicode scalar values, and a Vera `String` is — so the value has no UTF-8 encoding and cannot cross the WASM boundary at all. Both host parsers accepted the text and the *memory boundary* decided what happened next, differently and by accident: the browser's `TextEncoder` substituted U+FFFD, so `json_stringify` printed `{"k":"a�b"}` with nothing to tell the caller the value had changed, while the reference host died inside `_alloc_string` with a raw `UnicodeEncodeError`. Neither is a value the program can handle. The refusal now happens where the decoded value is known and before anything is marshalled, with one sentence naming the code point in canonical `\uXXXX` form so both escape casings produce the same message. Keys are covered as well as values, at any nesting depth — the key position is the one the issue's own reproduction used. The check does not overshoot: a *matched* high-then-low pair denotes one astral scalar value and still parses, which the batteries pin with matched pairs in every position, two pairs adjacent, and a pair at the end of a string. The two hosts' scans differ in a way worth recording, because the same rule reads differently against the two representations of a decoded value — `json.loads` has already combined a well-formed escape pair into one astral code point, so a plain D800–DFFF range test is complete on the reference host, while a JS string is UTF-16 and its scan must consume pairs before judging anything lone. With this and [#1306](https://github.com/aallan/vera/issues/1306), `md_parse` is the only operation on the shared surface still diverging (§12.9.3). + +- **A host callback's failure is a Vera error, not a Python traceback** ([#1302](https://github.com/aallan/vera/issues/1302)). `execute()` converted an escaping exception into `WasmTrapError` only when its type name was `Trap` or `WasmtimeError`. A host import raising an ordinary Python exception — `json_stringify` refusing a non-finite `JNumber`, the case that surfaced it — is re-raised through wasmtime's trampoline and arrives as, say, a `ValueError`, so the branch was skipped entirely: no classification, no source-map resolution, and the captured stdout/stderr dropped as the exception unwound. Measured on a program printing `"before"` and then `json_stringify(JNumber(nan()))`: **63 lines** of stderr across 17 Python stack frames, none of them naming the user's `.vera` file — and in `--json` mode no envelope at all, so a machine consumer got nothing parseable. It is now one line: `Error: json_stringify: NaN is not representable in JSON — RFC 8259 has no NaN or Infinity. Guard with float_is_nan / float_is_infinite before serialising.` The refusal itself was always right and is an instruction (DESIGN principle 1); only its presentation was wrong. The conversion is keyed on the **boundary** rather than on the exception's type, which is what makes the fix general: the guarded region is the guest invocation and nothing else, so everything arriving there is either a wasmtime trap or a host callback that raised, and every compiler phase has already finished. The taxonomy gains a `host_error` kind, carried in the JSON envelope's `trap_kind` beside the captured `stdout` (per [#522](https://github.com/aallan/vera/issues/522)); its `Fix` paragraph is empty for the same reason `contract_violation`'s is — the description already carries the specific instruction, and a canned paragraph beneath it would be noise. The original exception stays reachable as `__cause__` for anyone debugging the binding itself, and `VERA_DEBUG_HOST_ERRORS=1` re-raises it untouched so the Python frames are still one environment variable away (ENVIRONMENT.md). This closes the gap against the invariant already written on `host_print` in `vera/codegen/api.py`: *a user-level program must never produce a Python traceback regardless of what it does.* + - **A user-defined `fn get` / `fn put` is no longer hijacked by an enclosing handler** ([#1284](https://github.com/aallan/vera/issues/1284)). Three sites answered "does this `get` mean the user's declaration or the effect operation?" independently. The checker answers user-fn-first — `_check_call_with_args` looks a bare name up as a function before it looks it up as an operation, so a declaration named `get` owns every bare `get(...)` in its scope, which an arity or argument-type error at such a call site proves by reporting the *user's* signature (E201/E202). Codegen answered twice more: the declared-effect row in `vera/codegen/functions.py` withheld the intrinsic when `_fn_sigs` already owned the name, and the handler expression in `vera/wasm/calls_handlers.py` installed `get`/`put` unconditionally. From `vera check`-green source that produced, depending on the nesting shape, a **silently wrong value** (`nat_to_int(get(3))` under `handle[State](@Int = 5)` returned the cell's 5 for the function's 4, and the argument was not even emitted), a **module WASM validation rejects** (a `@Bool`-returning user `get` took `state_get_Int`'s i64 into an `i32` position; different-family nesting took the *enclosing* cell's getter at the wrong width), or a **spurious `[E602]`** in which the #1233 unaddressable-cell gate refused `main` outright, naming "a bare or qualified State operation `get`" the program never contained. The repair is one predicate, `vera.slots.bare_call_denotes_user_fn`, stating the checker's rule once and consumed by the bare-call dispatch in `vera/wasm/calls.py` (which now gates the clause-inline registry, the host-cell intrinsics and the addressability gate together), by the three bare-`FnCall` result-type inference sites, and by the monomorphizer's discovery walk — each passing its own name table, so the sites cannot answer differently about the table they share. Gating the *dispatch* rather than the *registries* is what makes it correct rather than merely consistent: the registries record which cell an op name reaches, which is true whatever the program's declarations are called, and withholding an entry answered both questions with one table. That is why the gate-only fix measured during PR #1283's review turned the loud skip into a differently-broken module, and why it also cost the qualified spelling its cell — `State.put(5)` in a function that also declares `fn put` compiled to `call $vera.put` and failed to link, which now lowers to the intrinsic the checker always meant. Discovery's `MonoContext.fn_names` moves to the same lookup-time question, so a `get(())` fixing a generic's type variable under a handler names the clone the rewrite emits. All 256 conformance and example programs emit byte-identical WAT. **The `W002` async-commutativity warning was the same defect in the checker's own file** and is corrected with them: `_collect_expr_effects` asked `lookup_effect_op` before the scoped function lookup — the last op-first consumer — so a user function named after an operation contributed the *operation's* parent effect to the commutativity analysis instead of its own declared row, wrong in both directions. A **pure** `fn get`, in a program containing no `State` at all, drew `async argument performs State effects`; a `fn get` that performs `IO`, under a row naming `Http` first, drew **no warning**, because the walk bound the name to `Http.get`, which is inside the commutative whitelist, and silently withheld the eager-evaluation warning the program is owed. Both are pinned with rename controls — the byte-identical program with the helper called `gett` / `fetch` was correct throughout — beside a control that an unshadowed bare `get(())` under a `State` row still warns, so the fix cannot degenerate into never reporting `State`. The walk's comment claiming it resolves "like the call checker above" is now true. One caveat the predicate did not close on its own: codegen's name table was not scope-accurate, so a name the call site cannot see still answered "user-owned" there — a property of the table rather than of the rule, closed by [#1299](https://github.com/aallan/vera/issues/1299) below. - **A bare call is lowered against the names its call site can see** ([#1299](https://github.com/aallan/vera/issues/1299)). The [#1284](https://github.com/aallan/vera/issues/1284) ownership predicate is one rule read over two tables, and only one of them was a scope. The checker's is a lexical walk; codegen passed `set(_fn_sigs.keys())` — a flat mirror of every symbol the whole compilation absorbed — so a bare `get(())` the checker had resolved to a `State` operation was lowered as a call to a declaration the body cannot name. Four source shapes reach it, all `vera check`-green and all one defect: an imported module's **private** `fn get` (invisible, but still compiled in because the module's own bodies call it), a **public** one a selective import excludes, a `where` helper of a **`forall` parent** (which keeps a bare `_fn_sigs` key beside its clone-qualified one where a non-generic parent's helper does not), and the ability operation `show`, which `E151` does not reserve and which reaches the same table through the *intrinsic* gate rather than the operation one. How it lands is a property of the widths, not of the route: where the invisible declaration and the cell share a WAT type the module loads and returns the wrong value (7007 where the cell holds 42007), where they differ it fails to load (`type mismatch: expected i64, found i32`), and the generic-`where` route is always loud — the bare key exists in the signature table while no bare *symbol* is emitted, so the call dies at WAT assembly on `unknown func: failed to find name $get` with no E-code. The repair splits the two questions the one set was answering. `_known_fns` keeps the flat registry for `_translate_call`'s guard rail, which asks whether a *resolved* target — already mono-mangled, already `mod$…` rerouted — has an implementation, and is flat by nature. A new `_scoped_fns` carries the names visible in the compiling declaration's **lexical** scope, and that is what the ownership predicate reads: its namespace's own declarations plus the public, in-filter names of the imports *that namespace* makes (spec §8.6.4 — imports are never inherited, so a transitively-reached module contributes nothing to the entry program), the prelude, and the `where` helpers of every enclosing function. Module scope alone would not have closed the third route: a generic's helper *is* in the module and still is not in a sibling's scope. The narrowing is a strict subset of the registry by construction — every `$`-bearing key is admitted unconditionally, since `$` cannot occur in a Vera identifier and a mangled name is never what a bare source call spells — so it can only withdraw a name the flat table wrongly claimed. diff --git a/CLAUDE.md b/CLAUDE.md index e20048b4..77d43898 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,6 +56,7 @@ vera errors [--json] # List the diagnostic code registry E001–E70 pytest tests/ -v # Run the test suite (see TESTING.md) VERA_JS_COVERAGE=1 pytest tests/test_browser.py -v # Browser tests with JS coverage VERA_EAGER_GC=1 vera run file.vera # Force GC on every alloc (see ENVIRONMENT.md, debug knob for #593-class GC-rooting bugs) +VERA_DEBUG_HOST_ERRORS=1 vera run file.vera # Re-raise a host callback's own exception (see ENVIRONMENT.md, debug knob for host-binding bugs) mypy vera/ # Type-check the compiler itself python scripts/check_conformance.py # Verify all 238 conformance programs (positives pass their level; negatives fail with their expected_error E-code) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f4096294..081edd13 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -147,7 +147,7 @@ pytest --cov=vera # with coverage VERA_JS_COVERAGE=1 pytest tests/test_browser.py -v # JS coverage ``` -PRs touching `vera/browser/runtime.mjs` have JavaScript coverage tracked by Codecov (via V8's built-in coverage). See [TESTING.md](TESTING.md) for the full testing reference -- coverage data, test helpers, and guidelines for adding tests. See [ENVIRONMENT.md](ENVIRONMENT.md) for all `VERA_*` environment variables (provider keys, runtime knobs, and debug flags like `VERA_EAGER_GC` for hunting GC-rooting bugs). +PRs touching `vera/browser/runtime.mjs` have JavaScript coverage tracked by Codecov (via V8's built-in coverage). See [TESTING.md](TESTING.md) for the full testing reference -- coverage data, test helpers, and guidelines for adding tests. See [ENVIRONMENT.md](ENVIRONMENT.md) for all `VERA_*` environment variables (provider keys, runtime knobs, and debug flags like `VERA_EAGER_GC` for hunting GC-rooting bugs and `VERA_DEBUG_HOST_ERRORS` for host-binding ones). **Doc-count gate**: any PR that adds tests will trip `scripts/check_doc_counts.py` if it doesn't also update the test counts in `TESTING.md` (per-file rows + overall total), `ROADMAP.md` (the "Where we are" line), and `README.md` (project-status line). Run the script locally to see exactly which numbers need updating: diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index 4801852e..2f59cd5e 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -15,6 +15,7 @@ Vera reads a small set of `VERA_*` environment variables. This document is the | [`VERA_DB_URL`](#vera_db_url) | Database connection for the `DB` effect | runtime | optional (defaults to `sqlite::memory:`) | | [`VERA_JS_COVERAGE`](#vera_js_coverage) | Opt-in V8 coverage during browser-parity tests | dev / CI | optional | | [`VERA_EAGER_GC`](#vera_eager_gc) | Force `$gc_collect` on every allocation — debugging knob for GC-rooting bugs | compile-time (dev) | optional | +| [`VERA_DEBUG_HOST_ERRORS`](#vera_debug_host_errors) | Re-raise a host callback's original exception instead of converting it — debugging knob for host-binding bugs | runtime (dev) | optional | ## Inference provider keys @@ -82,6 +83,20 @@ This was the diagnostic that cracked [#593](https://github.com/aallan/vera/issue **Cost.** Programs run orders of magnitude slower with `$gc_collect` on every allocation — never enable it in production or in normal test runs. It's a debugging knob, not a release-build option. Tests that exercise this knob live in `tests/test_codegen_closures.py::TestClosureReturnShadowPushBalance`. +## `VERA_DEBUG_HOST_ERRORS` + +A diagnostic knob for debugging the host bindings themselves. Set to `1`, `true`, or `yes` (case-insensitive, surrounding whitespace ignored — the same spellings [`VERA_EAGER_GC`](#vera_eager_gc) accepts) to make `execute()` re-raise a host callback's original Python exception instead of converting it to a `WasmTrapError`: + +```bash +VERA_DEBUG_HOST_ERRORS=1 vera run program.vera +``` + +Read by `vera/codegen/api.py::execute`; affects how a failure is *presented*, never whether one happens. + +**When to use it.** [#1302](https://github.com/aallan/vera/issues/1302) made every exception escaping the guest invocation arrive as a classified Vera error — a one-line `Error:` with the host's own sentence, the captured `stdout`, and a source backtrace — because a user-level program must never produce a Python traceback regardless of what it does. That is right for someone running a Vera program and unhelpful for someone who suspects the *binding* is wrong: the sentence survives, the Python frames that say where in the binding it came from do not. They remain on the exception's `__cause__`, which serves a library caller and not a person reading a terminal. This knob puts the frames back. + +**Cost.** None at runtime — the variable is read only on the failure path, and only after a host callback has already raised. It is still a debugging knob rather than a mode, and it disables more than a message. With it set, a program that would have exited with a clean Vera diagnostic exits with an interpreter traceback instead, so nothing that parses `vera run` output should be run under it — and `vera serve` reverts to its pre-[#1302](https://github.com/aallan/vera/issues/1302) behaviour, where the raw exception bypasses the `WasmTrapError` handler that answers the request, leaving the connection unanswered rather than returning a 500. The knob turns off the stronger invariant, not just the prettier output. Tests that exercise this knob live in `tests/test_runtime_traps.py::TestHostErrorDebugKnob1302`. + ## Adding a new environment variable When adding a new `VERA_*` variable to the codebase: diff --git a/FAQ.md b/FAQ.md index bb68a428..391ce369 100644 --- a/FAQ.md +++ b/FAQ.md @@ -170,7 +170,7 @@ vera compile --target browser examples/hello_world.vera # index.html ``` -Serve it with any HTTP server and open `index.html` — no build step, no bundler, no dependencies. The JavaScript runtime provides browser-appropriate implementations of the host bindings the browser target supports — the ones a page can host, which leaves a filesystem, an accept loop, a database and a model provider outside it by construction (spec §12.9.3 lists each and why): `IO.print` writes to the page, `IO.read_line` uses `prompt()`, and State, contracts, JSON serialization and Markdown rendering work identically to the wasmtime runtime. `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states — §9.7.1 and §9.7.3 — rather than by the two hosts happening to agree, which is what the parity suite checks them against. The two *parsers* are where the runtimes still differ, and each difference is tracked. `json_parse` disagrees on inputs outside RFC 8259: the reference host accepts `NaN` and the infinities where the browser refuses them ([#1306](https://github.com/aallan/vera/issues/1306)), and a `\uD800` escape becomes U+FFFD in the browser where the reference host fails outright ([#1308](https://github.com/aallan/vera/issues/1308)) — well-formed JSON parses the same on both. `md_parse` differs more widely: the two hand-written parsers disagree across nine measured classes of input the §9.7.3 subset leaves open, the largest by a wide margin being how a paragraph's plain-text runs are grouped — invisible to `md_render`, since the runs concatenate to the same text — and the rest render-visible, from how emphasis markers are scanned to block markers such as a `+` bullet or a list nested more than two deep. That one is tracked as [#1301](https://github.com/aallan/vera/issues/1301). +Serve it with any HTTP server and open `index.html` — no build step, no bundler, no dependencies. The JavaScript runtime provides browser-appropriate implementations of the host bindings the browser target supports — the ones a page can host, which leaves a filesystem, an accept loop, a database and a model provider outside it by construction (spec §12.9.3 lists each and why): `IO.print` writes to the page, `IO.read_line` uses `prompt()`, and State, contracts, JSON serialization and Markdown rendering work identically to the wasmtime runtime. `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states — §9.7.1 and §9.7.3 — rather than by the two hosts happening to agree, which is what the parity suite checks them against. `json_parse` reaches it from the other side, by accepted domain rather than by output form: §9.7.1 states what it takes — RFC 8259-valid text that decodes to finite numbers and strings of Unicode scalar values — so the JavaScript constants (`NaN`, `Infinity`, `-Infinity`) and a lone-surrogate escape are `Err` at the parse on both hosts, with one message, and every text inside the domain parses identically. `md_parse` is the one operation on the shared surface still to reach parity: the two hand-written parsers disagree across nine measured classes of input the §9.7.3 subset leaves open, the largest by a wide margin being how a paragraph's plain-text runs are grouped — invisible to `md_render`, since the runs concatenate to the same text — and the rest render-visible, from how emphasis markers are scanned to block markers such as a `+` bullet or a list nested more than two deep. That one is tracked as [#1301](https://github.com/aallan/vera/issues/1301). Two effects are refused outright rather than merely differing. `Inference` and `DB` return an explanatory `Err` from every operation in the browser, because the API key or database credential they would need is readable from page source and network traffic in client-side JavaScript. Reach them through a server-side endpoint and call it with `Http`, which does run in the browser — it is backed by `XMLHttpRequest`, not a stub. That refusal is a deliberate platform boundary; spec §9.5.5 states it for `Inference`. diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 17a93286..285ba87c 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -12,11 +12,8 @@ Defects in shipped compiler, runtime, or tooling behaviour — this table matche | An entry-file `data` declaration and a module's of the same name silently drop the caller when their shapes differ. `private data Json { JMine(Int) }` in the entry beside `private data Json { JBlob(Int) }` in an imported module is `vera check`-green and then compiles with **exit 0**, `ok: true`, and nothing but `[E602]`/`[E620]` **warnings** — and the entry's `main`, which calls the module's function, is absent from the exports (measured: `exports == ['consume']`, the one function that touches only the entry's own declaration). The artifact loads and simply lacks its entry point. The flat namespace holds one layout per name and the entry's declaration takes it — Pass 1 registers the main file's `data` over the Pass-0.5 module harvest, which only `setdefault`s — so the module's own constructors become `unknown constructor` inside its own bodies. Same one-layout-per-name defect as [#1277](https://github.com/aallan/vera/issues/1277) and **not** closed by its E621 rail, for a structural reason rather than an oversight: that rail compares a PRELUDE declaration against a module's, and an entry-file declaration *suppresses* the prelude's injection outright, so no prelude declaration ever reaches Pass 1.2 and the contending pair here — entry versus module — is one the rail cannot be asked about. Behaviourally identical at `8e08ec90` and after #1277's fix, so pre-existing rather than introduced. Fix direction: put the entry-versus-module pair through the same contention check, reusing the machinery #1277 installed — `vera/prelude.py`'s `data_decl_shape` already decides whether two declarations of one name can share a layout (it is what keeps a module's restatement of a prelude type legal), and the conformance manifest's `expected_error_stage: "compile"` already expresses a check-green/compile-refused negative — leaving the ruling: a new code, or E609 widened from module-versus-module to take the entry file as a namespace, located at both declarations. | [#1312](https://github.com/aallan/vera/issues/1312) | | A module-declared generic instantiated at a type that comes from an EFFECT-OPERATION result never registers its clone. Codegen reports a compilation note (`call target 'mod$$$' not registered`) and silently drops the calling function, so the emitted module has no `main` — from a program `vera check` and `vera verify` both pass. Two conditions, bisected and both necessary: the generic is declared in a **module** (so its clone is the `mod$`-mangled form) and its type argument is inferred from an effect operation's result. Neither nesting under a `where` helper nor `private` visibility is needed — `public` fails identically. Controls that pass: a literal type argument, a type argument from a plain module-function result, and the identical shape with the generic declared in the entry file. This is the `mod$`-mangled REGISTRATION path for module generics (the #1274/#1281 machinery), distinct from #1299's scope-table divergence — measured identical on `release/v0.1.12`'s base and on the #1299/#1281 branch, so that fix neither causes it nor changes it. | [#1310](https://github.com/aallan/vera/issues/1310) | | A `type` alias whose name is also a registered ADT's compiles at the ADT's **pointer width**, not the alias body's. `type Option = Int;` with a `@Option` parameter is `vera check`-green and `vera verify`-green (4 Tier 1) and then dies at `vera run` with `Invalid input WebAssembly code at offset 79: type mismatch: expected i64, found i32`: codegen emits `(param $p0 i32)`, the ADT's heap pointer, where the checker resolved the alias body and typed the slot `Int`. Single file, no modules, and the byte-identical program with the alias renamed `Widget` emits `(param $p0 i64)` and prints 42 — so it is the collision with a registered ADT name, not aliases in general, and it is reachable for every name in `_adt_layouts`: the built-ins `_register_builtin_adts` installs as much as the prelude's. **Loud only by luck of the widths.** `type Ordering = Bool` runs and answers correctly because the alias body and the ADT pointer are both i32, so at matching widths the same resolution disagreement raises no WebAssembly error at all and the body reads a pointer as its aliased value — a silent wrong answer from a check-green, verify-green program is the class this row tracks, and the i64/i32 case above is merely the arm that happens to be caught by validation. Spec §8.4.1 permits the declaration — the prelude's and the built-in data types are ordinary declarations a program may name and shadow, the reserved namespace being the `Vera` prefix alone (E154) — so the fix is to make codegen agree with the checker rather than to reject the program. `vera/naming.py`'s `_resolve_named` already states the branch order (type parameter → primitive → alias, arity-checked → declared ADT); fix direction: route the WASM-width derivation through that one resolver instead of consulting `_adt_layouts` ahead of the alias maps. | [#1309](https://github.com/aallan/vera/issues/1309) | -| `json_parse` of a lone-surrogate escape substitutes U+FFFD in the browser and crashes the reference host. `json_parse("{\"k\":\"a\\ud800b\"}")` — whose Vera source is plain ASCII, so nothing refuses it earlier — prints `{"k":"a�b"}` in the browser and kills `vera run` with a `UnicodeEncodeError` traceback natively. Both hosts decode a JSON `\uD800` escape into a string holding an unpaired surrogate, which has no well-formed UTF-8 encoding, and they then part company writing it into WASM memory: `TextEncoder` replaces it with U+FFFD and returns, while `_alloc_string`'s `.encode("utf-8")` raises. Neither host is obviously right — one silently swaps a different valid string for the requested one, the other refuses but through the raw-traceback path [#1302](https://github.com/aallan/vera/issues/1302) tracks. Sibling of [#1306](https://github.com/aallan/vera/issues/1306): both are the same question about `json_parse`'s domain — which inputs it accepts, and which call refuses the ones it should not. The canonical serializers are NOT implicated: `dumps_canonical` emits a raw surrogate where the browser's walk escapes it, but the string boundary eliminates surrogates before either can see one, so the divergence is unreachable from there. Fix direction: rule on whether a Vera `String` is UTF-8 by construction — if it is, `json_parse` refuses the escape on both hosts and says so; if it is not, both hosts need one agreed lossy or WTF-8 representation. | [#1308](https://github.com/aallan/vera/issues/1308) | | The checker resolves a bare call to a SIBLING function's `where` helper. Spec §5 makes a helper local to its parent, and the checker's own `_lookup_function_scoped` implements that — it walks the enclosing frame stack and reads each frame's direct helpers — but it then falls back to `env.lookup_function`, and `vera/registration.py` has recursed every helper into that flat `TypeEnv`. So a top-level `other()` calling `helperx(7)`, where `helperx` is a helper of an unrelated `holder`, is **check-green and verify-green** and then refused by codegen (`Function 'helperx' is not defined in this module and was not found in any imported module`) — the helper is emitted as `holder$where$helperx`, so the bare call has no target. Loud, never a wrong answer. The op-name variant is the one that matters for the #1284 ownership predicate: with the helper named `get` and the sibling reading a `State` cell by bare `get(())`, the checker binds the HELPER and reports `[E202] Argument 0 of 'get' has type Unit, expected Int` where spec §7.4 resolves the operation — so the checker rejects a program codegen compiles correctly, the two tables disagreeing in the CHECKER's direction for the first time. Codegen's `_scoped_fns` (#1299) implements the spec rule; the fix here is a checker change, whose new rejections need their own blast-radius pass. | [#1307](https://github.com/aallan/vera/issues/1307) | -| `json_parse` accepts `NaN`, `Infinity` and `-Infinity` on the reference runtime and refuses them in the browser. The reference host calls `json.loads`, whose default `parse_constant` admits all three; the browser host calls `JSON.parse`, which refuses them as RFC 8259 requires. Measured through identical module bytes under both runtimes, `NaN`, `Infinity`, `-Infinity` and a nested `[NaN]` each take the `Ok` arm natively and the `Err` arm in the browser — a §12.9.3 divergence on the shared surface, and one no test covered because every JSON input in the parity battery is RFC-conformant. The hosts also disagree about *which call* refuses: the browser returns a handled `Err` from the parse, while natively the value parses and the following `json_stringify` raises the non-finite refusal [#1293](https://github.com/aallan/vera/issues/1293) installed, which [#1302](https://github.com/aallan/vera/issues/1302)'s host-callback path then presents as a Python traceback. Fix direction: hand `json.loads` a `parse_constant` that rejects, so the reference host refuses at the point RFC 8259 and the browser already do. | [#1306](https://github.com/aallan/vera/issues/1306) | | `json_keys`' result cannot be used from compiled Vera. The `Some` arm of its `Option>` binds a match binder of type `Array`, and codegen emits that binder's local as `(local $lN i32_pair)` — `i32_pair` being the internal String `(ptr, len)` spelling rather than a WASM value type — so the module fails WAT assembly with `unexpected token, expected one of: i32, i64, …`. A ten-line `public fn key_count(@Json -> @Int)` matching `json_keys` and calling `array_length` on the binder passes `vera check` and fails `vera compile`; `string_join` on the same binder fails identically. Loud rather than wrong, and narrow: `array_length(map_keys(...))` compiles, so `Array` alone is fine, and `json_get_array`'s `Some(@Array)` binder compiles, so a binder alone is fine — it is the pair that breaks. Fix direction: give the binder's local the array's own value type instead of its element's. | [#1305](https://github.com/aallan/vera/issues/1305) | -| A non-finite `JNumber` reaching `json_stringify` fails with the right sentence and the wrong presentation: `vera run` emits a **61-line, 3,214-character Python traceback** — 15 stack frames, 7 of them inside `.venv/`, and **no mention of the user's `.vera` file at all** — with the actual message (`json_stringify: NaN is not representable in JSON — RFC 8259 has no NaN or Infinity. …`) as its last line. A contract violation in the same program shape produces **5 lines**: the failed clause and a source backtrace with `.vera` line numbers. The buffered stdout is discarded on the way out rather than surfaced, so the `#522` output-preservation contract is lost too. The invariant this breaks is already written down, in a comment on `host_print` in `vera/codegen/api.py`: "A user-level program must never produce a Python traceback regardless of what it does — the `WasmTrapError` contract from #516/#522/#547 holds here too." Root cause: `execute()` converts an exception into `WasmTrapError` only when `type(exc).__name__` is `Trap` or `WasmtimeError`, and a `ValueError` raised inside a host callback arrives at that handler as a `ValueError`, so the branch is skipped — no classification, no source-map frame resolution, no captured output. `json_stringify` is the case that surfaced it ([#1293](https://github.com/aallan/vera/issues/1293)), but the gap is generic: any host callback raising a non-`Trap` Python exception takes the same path. Fix direction: give host-callback failures a Vera-native shape — a dedicated error type classified alongside the traps, or widen the conversion to any exception escaping a host import — carrying the captured stdout and a source-mapped backtrace either way. | [#1302](https://github.com/aallan/vera/issues/1302) | | `md_parse` diverges between the native and browser runtimes on **211 of 1,471** adversarial inputs (14.3%) and **329 of 4,858** blank-line-separated sections of the project's own documentation (6.8%), measured at [PR #1303](https://github.com/aallan/vera/pull/1303) by comparing the two ADTs directly rather than their renders. The second denominator is the repository's own Markdown, so it moves whenever a document gains or loses a section; 11 of those 4,858 also differ in the rendered output. Nine classes, each with a one-line repro. The largest by a wide margin — 173 of the 211 — is *plain-text run grouping*: the browser emits one `MdText` per scan segment where the reference coalesces adjacent runs, so `**unclosed` is `[MdEmph([]), MdText("unclosed")]` natively and `[MdText("*"), MdText("*unclosed")]` in the browser. That class is invisible to `md_render` (the runs concatenate to the same text) but not to a Vera program that matches on the ADT, which is what makes it a §12.9.3 violation rather than a cosmetic one. The rest are render-visible: emphasis/strong scanning (`***both***` renders `**both****` natively, `***both***` in the browser); list-continuation indent width, where the reference strips exactly two (or three) characters and the browser strips all leading whitespace (`- a\n b` → `- a b` vs `- a b`); a `+` bullet, unrecognised in the browser; an `n)` ordered marker, likewise; a loose list, one list natively and two in the browser (`- a\n\n- b`); nesting past two levels, flattened in the browser (`- a\n - b\n - c`); a thematic break with internal spaces (`* * *`); and a table without a separator row (`\| a \| b \|\n- li`). Neither implementation is the specification — §9.7.3 pins the ADT, not the grammar that produces it — so closing this means choosing a parse for each class and stating it, then mirroring. Successor to [#1294](https://github.com/aallan/vera/issues/1294), which closed the `md_render` half; the parity suite pins the shapes they do agree on, so a regression on one of those goes red. | [#1301](https://github.com/aallan/vera/issues/1301) | | A postcondition may name a `State` the function's effect row never declares, and `vera check` accepts it: `ensures(new(State) == false)` under `effects(>)` reports OK, then `vera compile` fails with **E699** — the internal-compiler-error diagnostic whose own text says the type checker should have rejected the input, which is exactly the situation. Both forms land there for the same reason (no cell of that family exists, so `old()` finds no snapshot local and, since [#1285](https://github.com/aallan/vera/issues/1285), `new()` finds no getter). Loud and never a wrong answer, so this is diagnostic quality rather than soundness — but it is a check-green program that cannot compile, reported against the compiler instead of against the program, with a bug-report request the user should not act on. Before #1285 the `new()` side was worse than loud: the name-keyed lookup found the row's other getter and silently read the wrong cell. Fix direction: validate an `OldExpr`/`NewExpr`'s effect reference against the declared row where the checker already validates the rest of the clause, one rule for both forms; `test_a_family_the_row_does_not_declare_is_loud_on_both_sides` pins today's E699 and is the test to flip. | [#1298](https://github.com/aallan/vera/issues/1298) | | Spec §1.4 says its reserved keywords MUST NOT be used as function names, and for **seventeen of them nothing enforces it**: `then`, `else`, `data`, `type`, `module`, `import`, `public`, `private`, `requires`, `ensures`, `invariant`, `decreases`, `effect`, `with`, `in`, `where` and `pure` all declare and pass `vera check` as `public fn (@Int -> @Int)`. (`handle` is an eighteenth acceptance and is the sanctioned host-entry-point carve-out, §5.2; the other eleven names — `resume`, `exists`, `forall`, `match`, `assert`, `assume`, `fn`, `let`, `if`, `true`, `false` — are refused **E153**.) They are not even traps: `private fn with(@Int -> @Int)` and its `where` / `type` / `pure` / `requires` / `import` siblings are **callable** from the same file, so the declaration resolves and runs like any other function. The comment above `_KEYWORD_FN_NAMES` in `vera/checker/registration.py` gives the opposite as the reason those names are absent from the set — that the contextual lexer "does not admit them as a function name, so no declaration reaches this checker at all" — so the omission rests on a premise the tree refutes, and neither the parser nor the checker holds the MUST. Two resolutions, and the choice is a ruling rather than a mechanical fix: extend `_KEYWORD_FN_NAMES` so the seventeen join `resume` under E153, or narrow §1.4's MUST to the names actually reserved. The `resume` precedent leans to enforcing — `resume` is likewise not a keyword token anywhere and likewise parses, and was reserved *at the checker* precisely because the parser had no keyword to refuse it with — and E153's own rationale, that a keyword names one construct and nothing else (DESIGN principle 3), does not depend on the name being a trap. Until the ruling lands, §1.4's `handle` carve-out sentence deliberately makes no enforcement claim, so the spec does not assert a MUST it cannot back. | [#1296](https://github.com/aallan/vera/issues/1296) | diff --git a/SKILL.md b/SKILL.md index 5310f12b..b71d5d89 100644 --- a/SKILL.md +++ b/SKILL.md @@ -936,6 +936,13 @@ decimal_to_float(@Decimal.0) -- returns Float64 (potentia The `Json` type has six constructors: `JNull`, `JBool(Bool)`, `JNumber(Float64)`, `JString(String)`, `JArray(Array)`, `JObject(Map)`. It is provided by the standard prelude — no `data` declaration needed. +**What `json_parse` accepts.** Exactly RFC 8259-valid text that decodes to finite numbers and strings of Unicode scalar values (spec §9.7.1). Anything else is `Err`, at the parse, with the same message on the CLI and in the browser — Vera defines this domain rather than inheriting whichever one the host parser implements. Two consequences are worth knowing before you write the `Err` arm: + +- A **non-finite number** never parses, however it is written. The constants `NaN`, `Infinity` and `-Infinity` are not JSON at all; a number that merely *overflows*, like `1e999`, is syntactically fine and still refused, because what it decodes to is an infinity. Underflow is different — `1e-999` gives you `0` and parses. If a producer you do not control emits any of these, fix the producer or pre-process the text; there is no flag to admit them. +- A **lone surrogate** escape (`\ud800` with no matching partner) never parses either: its decoded value is not a Unicode scalar, and a Vera `String` is. A *matched* pair is ordinary — `"\ud83d\ude00"` parses fine and gives you the astral character. + +Everything else that fails is a plain syntax error, and that message is the host parser's own. + ```vera json_parse("{\"name\":\"Vera\"}") -- returns Result @@ -2495,7 +2502,7 @@ Current reference-implementation bugs that an agent writing Vera code is likely |---|---|---|---| | Rare conformance-gate flake | `ch05_closure_nat_return` trapped once in a full conformance run and never again (~960 clean attempts) — suspected runtime/GC timing interaction, not a compiler defect. | If CI reds on this program with `Reached unreachable` in `main`, re-run and report on the issue with wasmtime version + load conditions — do not chase the compiler. | [#996](https://github.com/aallan/vera/issues/996) | -When a Vera program type-checks cleanly, compiles without errors, and then produces a runtime trap you can't explain, runtime trap diagnostics are now Vera-native end-to-end: each trap carries a `kind` label (`divide_by_zero` / `out_of_bounds` / `stack_exhausted` / `unreachable` / `overflow` / `contract_violation` / `unknown`), a per-kind `Fix:` paragraph naming the canonical remediation, and a source backtrace pointing at the offending Vera function and line — not just `wasm trap: `. Tail-recursive iteration runs in constant WASM stack space for both non-allocating ([#517](https://github.com/aallan/vera/issues/517), v0.0.126) and allocating ([#549](https://github.com/aallan/vera/issues/549), v0.0.154) tail calls — the latter prepends a `$gc_sp` restore before each `return_call` to keep the shadow stack bounded across iterations. +When a Vera program type-checks cleanly, compiles without errors, and then produces a runtime trap you can't explain, runtime trap diagnostics are now Vera-native end-to-end: each trap carries a `kind` label (`divide_by_zero` / `out_of_bounds` / `stack_exhausted` / `unreachable` / `overflow` / `contract_violation` / `host_error` / `unknown`), a per-kind `Fix:` paragraph naming the canonical remediation, and a source backtrace pointing at the offending Vera function and line — not just `wasm trap: `. Tail-recursive iteration runs in constant WASM stack space for both non-allocating ([#517](https://github.com/aallan/vera/issues/517), v0.0.126) and allocating ([#549](https://github.com/aallan/vera/issues/549), v0.0.154) tail calls — the latter prepends a `$gc_sp` restore before each `return_call` to keep the shadow stack bounded across iterations. ## Specification Reference diff --git a/TESTING.md b/TESTING.md index f671d05d..35d42f3b 100644 --- a/TESTING.md +++ b/TESTING.md @@ -35,6 +35,9 @@ VERA_JS_COVERAGE=1 pytest tests/test_browser.py -v # V8 coverage via c8 # GC-rooting diagnostic (forces $gc_collect on every alloc, see ENVIRONMENT.md) VERA_EAGER_GC=1 pytest tests/test_codegen_closures.py::TestClosureReturnShadowPushBalance -v +# Host-binding diagnostic (re-raises a host callback's own exception, see ENVIRONMENT.md) +VERA_DEBUG_HOST_ERRORS=1 pytest tests/test_runtime_traps.py::TestHostErrorDebugKnob1302 -v + # Type checking mypy vera/ # strict mode @@ -161,6 +164,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_codegen_io.py` | 42 | 821 | IO operations (#135: read\_line, read\_file, write\_file, args, exit, get\_env, sleep, time, stderr), Markdown + Regex host bindings (#419 split) | | `test_codegen_collections.py` | 69 | 1,141 | Map + Set collections (#62), wrapper-handle bit-31 tagging (#578) (#419 split) | | `test_codegen_json.py` | 116 | 1,112 | Json collection, typed accessors (#419 split), canonical serialization (#1293 — `format_json_number`'s ECMAScript boundaries and `dumps_canonical`'s shape, insertion-ordered keys, non-finite refusal, and rejection of values outside `read_json`'s domain) | +| `test_json_accept_domain_1306_1308.py` | 97 | 742 | `json_parse`'s accepted domain on the reference host (#1306, #1308): spec §9.7.1 states it as RFC 8259-valid text that decodes to finite numbers and strings of Unicode scalar values, and all three exclusions are pinned end-to-end through a compiled program that reports which arm it took plus the whole `Err` message — the JavaScript constants at the top level and nested in containers, with the first of two naming the refusal; a number that OVERFLOWS to an infinity (`1e999`, `-1e999`, `[1e999]`, `{"a":1e309}`, `1E999`, `[[1e999]]`), the second entry route to a non-finite `JNumber` and the one both host parsers accepted, so it diverged from the stated domain on both hosts at once rather than between them; the same overflow in its INTEGER spelling (`1` followed by 309, 310 and 400 zeros, signed and nested), which was reference-host-only because `json.loads` returns an `int` there and a float-only range check never saw it, with the bound pinned as the double ROUNDING boundary against `float()` as oracle and `int(sys.float_info.max) + 1` as the control that separates it from the obvious-but-wrong bound; and a lone surrogate parameterised over position (value, key, array element, nested, top-level string) and escape casing. The controls carry the weight the refusals cannot: matched surrogate pairs (single, adjacent, at end of string, literal astral) still parse, `1e308` / `-1e308` / the largest representable double still parse so the overflow refusal is a boundary and not a wall, **underflow is decided rather than assumed** (`1e-999` decodes to `0`, finite and in the domain, so it is accepted — the plausible wrong answer is symmetry with overflow), `"NaN"` as a string value and as a key is ordinary JSON, and text malformed for any other reason keeps its host-native syntax message — the constant-lookalike shapes (`[Infinity_x]`, `{Infinity:1}`) that a raise-on-sight `parse_constant` hook would have misreported, which is why the hook records and the refusal is decided after the parse, and the sign/case shapes (`-NaN`, `[-NaN]`, `+Infinity`, `infinity`, `nan`, `-Infinityx`) that a token scan without a value-start constraint would have claimed. Unit tests on `first_domain_violation` — the ONE document-order walk both value-level exclusions share, so "whichever comes first names the refusal" needs no precedence table — pin the traversal directly (key before its own value, earlier entry before later, D800/DFFF inclusive at both ends, an `int` and a `bool` never read as a non-finite number, and the NaN arm that no JSON text can reach) where the end-to-end probe can only observe the first refusal. The cross-host half is `TestBrowserJsonAcceptDomainParity1306_1308` in `test_browser.py` | | `test_codegen_decimal.py` | 57 | 779 | Decimal collection, Decimal monomorphization (#419 split) | | `test_codegen_host_effects.py` | 71 | 1,123 | Html/Http/Inference host effects, provider dispatch, postcondition host-import propagation (#823) (#419 split) | | `test_codegen_nat_guards.py` | 61 | 1,435 | **`@Nat` runtime guards**: subtraction underflow (#520) and binding-site narrowing (#552 let site; #747 tuple-destructure / match-bind / ADT sub-pattern / ctor-field / call-arg sites; #758 per-leaf function-return guards incl. type-alias returns and TCO preservation on mixed-arm tails — `i64.lt_s; unreachable` net, `@Int` targets exempt) (#419 split); #758 `@Int -> @Nat` return-position guard; #983 review adds alias-aware return gates (`type Count = Nat` narrow, `type MyInt = Int` widen), the alias-to-refinement single-guard exclusion, and the per-narrowing-leaf emission that keeps a non-narrowing `@Nat -> @Nat` recursive tail call's `return_call` (TCO) intact. #1256 extends both alias-aware gates to a parameterised alias APPLICATION (`type Ident = T; type Count = Ident;`), which the name-only chase resolved to the bare head `Ident` — so neither gate fired and `f(0 - 5)` returned -5 through the `@Nat` slot, #983's silent negative one spelling over. Each parameterised case carries its unparameterised twin as the oracle (the claim is that the two spellings compile to the SAME guard, which a bare presence assertion would not catch losing), plus the run-trap on the violating value, the pass on a satisfying one, and the refinement-over-application control that pins the `_refinement_guard_parts` conjunct still keeps a refined return single-guarded | @@ -201,7 +205,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_tester_coverage.py` | 49 | 1,395 | Tester coverage gaps: String/Float64/ADT parameter input generation, Bool/Byte parameters, unsatisfiable preconditions, type expression edge cases, FP model-value extraction (NaN/Inf/signed-zero, #797), the #1208 naming pin (the threaded alias environment canonicalizes a type-argument alias), and the #1216 resolution set — an alias-typed parameter is trialed rather than skipped, its alias-spelled `requires` constrains the generated inputs, a refined alias reaches Z3 with its predicate so no trial violates codegen's entry guard, a `forall` variable shadowing a same-named module alias still resolves to an unsupported type variable, and a parameter that resolves to a non-encodable type still skips with that type named; plus the #1229 set — an UNTRANSLATABLE input constraint skips the function naming the blocking conjunct instead of scoring the resulting entry-guard trap as a contract failure, on the reported `string_length` repro, on a mixed clause (only the untranslatable conjunct is named), on a quantified precondition and on a refined parameter's predicate, with a Tier-3 control that must still be tested so a skip taxonomy firing on everything cannot pass | | `test_markdown.py` | 94 | 610 | Markdown parser: block/inline parsing, rendering, round-trips, edge cases | | `test_lsp.py` | 146 | 2470 | LSP transport + coordinate layer (#222 Phase C) and language features (#222 Phase D): parametrized code-point↔UTF-16 goldens incl. astral-plane fixtures and surrogate-pair snapping, Span (1-based, exclusive-end) and SourceLocation (0-based col) → LSP Range conversions, point→token-range widening, DocumentStore open/change/close + index invalidation, an in-process handler-drive test, and one stdio end-to-end round-trip against the real `vera lsp` subprocess (initialize → didOpen → shutdown → exit) pinning serverInfo + textDocumentSync capabilities; plus the Phase D feature suite — parse-error single-diagnostic path, type-error verification short-circuit, tier=3 in E520 diagnostic data, per-function tier Hint synthesis (and its suppression for functions with violated obligations), smallest-enclosing-span hover, De Bruijn slot goto (most-recent-parameter jump, out-of-range None, off-slot None, and the #1208 keying pins: a parameterised reference resolves, an alias-spelled parameter is reachable from a canonically-spelled reference, and a `forall` variable shadowing a module alias lands on the right parameter), and typed-hole completion (inside/after hole, away-from-hole None); plus the Phase E speculativeEdit suite — identical-text all-unchanged, breaking edit surfaces newly_undischarged (violated nat_sub) with canonical state untouched, strengthening edit surfaces newly_discharged, parse/type errors report ok:false, deleted functions report removed, proof_delta purity; plus the Phase F1 proposeEdit suite — the apply gate (clean and strengthening edits apply, breaking and non-compiling edits refuse), force overriding both gates with the delta still reported, wiring against a structural fake server (apply round-trip with exact full-document replacement range, refuse touches no canonical state, unopened-URI clamp sentinel), and full-document-range goldens (trailing-newline virtual line, UTF-16 end column); plus the Phase F2 strengthenContract suite — splice goldens (first-clause-only replacement with byte-identical remainder, ensures variant, unknown-fn None), the call-site audit pin (tightened precondition refused with newly_undischarged call_pre items, canonical state untouched), provable-ensures strengthening applies, and the three splice-target refusal paths (no analysis, unparseable document, unknown function); plus the Phase F3 addEffect suite — transitive-caller closure goldens (diamond in declaration order, leaf, unknown-fn None, recursion appears once), handler bounding (#725: a caller discharging the effect around its only call site drops out of the closure and is not rewritten, while a second unhandled path, a call in a handler clause, a handler for another effect, a handler naming a different instance of the same effect (`handle[State]` against a `State` propagation, which the checker does not discharge — end-to-end that caller must still be rewritten and the candidate must still apply, with the matching `State` propagation against the same fixture as the positive control that this handler key does prune something), and a bare `where`-helper call all keep it in, as does a refinement type argument at either depth (`Exn<{ @Int \| p }>` and `Exn>` render as their bare base type but discharge nothing of `Exn` — also pinned end-to-end), while an unparameterised `handle[IO]` does bound an `IO` propagation, a whitespace-spelled `State< Int >` request still bounds, and nesting bounds in either order (a matching handler inside a foreign one, a foreign one inside a matching one) — plus a key-level pin that `handle[Mod.IO]` keeps its module, and the effect-less query pinned handler-unaware; plus the two boundary pins the review added — a call in the handler's STATE INITIALISER keeps its edge, since the initialiser is evaluated in the enclosing scope before the handler is installed (pinned beside the E125 the checker raises there against a `pure` caller, with the identical call in the handler body clean as the contrast), and an alias-spelled handler does not bound a `State` propagation though the checker discharges it (`handle[State]` with `type MyAlias = Int` — the spelling comparison's under-prune, #1292, with the alias-spelled request as the control that does prune)), effect-row rewrite goldens (pure to singleton set, source-preserving append, already-present None, base-name identity blocking State next to State), diamond propagation applying one multi-site candidate with the bystander untouched, mixed append/replace rows with already-satisfied callers skipped, the fully-satisfied no-op shape, and the two refusal paths; plus the #728 instruction-contract suite — the LSP message carries description, rationale, and the Fix: paragraph (also pinning single E501 emission at the LSP surface), and a bare diagnostic maps to the description alone | -| `test_browser.py` | 347 | 4,733 | Browser parity: Python/wasmtime vs Node.js/JS-runtime output equivalence across IO, State, contracts, Markdown, Regex, and the examples the browser target can execute (two explicit lists in the file, not the whole `examples/` directory — interactive stdin, file IO, `DB` and the non-standalone `modules` example are excluded with their reasons recorded); plus the #349 `runtime.mjs` coverage battery — per-value-type and per-key-type Map variants, per-element-type Set variants, cold `Decimal` branches (exact-zero sign, negative-shift division, `decRoundPlaces` special cases, non-finite storage), `readJson`/`json_stringify` across every Json ADT tag, the Regex/Json `Result.Err` arms, and nested-Markdown walks. Two operations carry a canonical form the specification states rather than merely agreeing across the hosts, so their batteries assert more than equality: `json_stringify` (spec §9.7.1) pins the expected string on every Json ADT tag and on the number-rendering boundaries, checks three-pass idempotence, checks that a non-finite `JNumber` fails on both hosts *and* prints nothing, pins the object key orders an ordinary JS object cannot carry (array-index keys, which ES enumeration hoists to the front in ascending numeric order, and a `__proto__` key, whose assignment writes a prototype instead of a field) on both a parsed and a program-built object, and checks the reference host's own number rendering differentially against a real `JSON.stringify` over doubles drawn from raw bit patterns; `md_render` (§9.7.3) pins the expected render, re-renders it to prove the fixed point, runs the round-trip property over a corpus carrying the container and multi-line shapes a flat corpus misses, and renders `MdBlock` values the test builds directly, since several renderer rules — a container's child separator, an empty container, a code span wider than one backtick — are unreachable through `md_parse`. `md_parse` itself is not yet at parity and its remaining divergence classes are a tracked bug ([#1301](https://github.com/aallan/vera/issues/1301)); the suite pins the shapes the two implementations agree on | +| `test_browser.py` | 347 | 4,733 | Browser parity: Python/wasmtime vs Node.js/JS-runtime output equivalence across IO, State, contracts, Markdown, Regex, and the examples the browser target can execute (two explicit lists in the file, not the whole `examples/` directory — interactive stdin, file IO, `DB` and the non-standalone `modules` example are excluded with their reasons recorded); plus the #349 `runtime.mjs` coverage battery — per-value-type and per-key-type Map variants, per-element-type Set variants, cold `Decimal` branches (exact-zero sign, negative-shift division, `decRoundPlaces` special cases, non-finite storage), `readJson`/`json_stringify` across every Json ADT tag, the Regex/Json `Result.Err` arms, and nested-Markdown walks. Two operations carry a canonical form the specification states rather than merely agreeing across the hosts, so their batteries assert more than equality: `json_stringify` (spec §9.7.1) pins the expected string on every Json ADT tag and on the number-rendering boundaries, checks three-pass idempotence, checks that a non-finite `JNumber` fails on both hosts *and* prints nothing, pins the object key orders an ordinary JS object cannot carry (array-index keys, which ES enumeration hoists to the front in ascending numeric order, and a `__proto__` key, whose assignment writes a prototype instead of a field) on both a parsed and a program-built object, and checks the reference host's own number rendering differentially against a real `JSON.stringify` over doubles drawn from raw bit patterns; `md_render` (§9.7.3) pins the expected render, re-renders it to prove the fixed point, runs the round-trip property over a corpus carrying the container and multi-line shapes a flat corpus misses, and renders `MdBlock` values the test builds directly, since several renderer rules — a container's child separator, an empty container, a code span wider than one backtick — are unreachable through `md_parse`. plus the #1306/#1308 accept-domain battery (`TestBrowserJsonAcceptDomainParity1306_1308`), which runs one `.wasm` under both runtimes and compares the WHOLE stdout — arm taken and `Err` message together — over the JavaScript constants, over numbers that overflow to an infinity in both their exponent and integer spellings, and over lone surrogates at every position a string can occupy, with the expected sentences imported from `vera/wasm/json_serde.py` so `runtime.mjs`'s hand-copied duplicates are held against the originals, beside acceptance controls (matched pairs, finite boundary values, underflow to `0`, `"NaN"` as a string value), a ten-case host-native-message battery pinning that neither the substitute-and-re-parse probe nor the value-start constraint hijacks an unrelated syntax error (`-NaN` is the case needing both rules — the substitution alone turns it into `-0` and manufactures a refusal the reference host never makes), a precedence case fixing that a non-finite constant outranks a lone surrogate on both hosts though each reaches that answer by a different route, and a document-order case fixing that overflow and lone surrogate are resolved by one walk rather than by two per-host precedence rules that would agree on every single-violation document; `md_parse` itself is not yet at parity and its remaining divergence classes are a tracked bug ([#1301](https://github.com/aallan/vera/issues/1301)); the suite pins the shapes the two implementations agree on | | `test_conformance.py` | 1190 | 144 | Parametrized conformance suite: parse, check, verify, run, format idempotency across 238 programs; a negative entry fails at the stage `expected_error_stage` names (`check`, the default, or `compile` — which also asserts the program type-checks cleanly first) | | `test_prelude.py` | 29 | 585 | Prelude injection: Option/Result/array operation detection, combinator shadowing, type aliases, the reserved namespace every injected alias declaration lives in — checked against the checker's own E154 regex rather than a second spelling of the rule, since an alias the prelude injects outside it is one codegen resolves and the checker leaves opaque (#1184/#1221) — end-to-end compilation | | `test_checker_apply_fn.py` | 18 | 455 | #854 — `apply_fn` as a checker special form: zero-warning pins (API + CLI `--json` + closures.vera), E201 arity / E202 type / non-function-first-arg errors, E122/E125 effect-row enforcement for applied fn values, E151 redefinition rejection, variadic two-param application, prelude combinator regression pins | @@ -222,7 +226,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_check_limitations_sync.py` | 6 | 108 | `check_limitations_sync.py` section extraction: table-rows-only issue harvesting, prose-link exemption, bounding at the next second-level heading, `None` for absent or sub-level headings so renamed sections fail loudly; plus the #852 fail-loud rule: an UNKNOWN issue state under `--check-states` (gh missing / auth failure / timeout) is an error, never a silent pass | | `test_doc_annotations.py` | 23 | 340 | `scripts/doc_annotations.py` — the inline `vera:skip-` fence-annotation reader and shared `run_parse_only_gate` used by the doc-block gates ([#538](https://github.com/aallan/vera/issues/538)): markdown/HTML scanning (annotation attached to the following fence / `
`, stacked directives), hard problems (malformed, dangling incl. EOF, duplicate-stage, unknown-stage, unterminated fence / unclosed `
`; prose mentions without comment syntax are fine), the gate round-trip semantics via `evaluate_block` (unannotated failure fails, annotated failure skips, annotated PASS is a stale annotation, skip-check still runs parse first and stops the pipeline), unsupported-stage detection for parse-only gates, and `strip_annotations` (annotation lines removed, other HTML comments survive) |
 | `test_doc_builtin_shadowing.py` | 8 | 107 | `check_doc_builtin_shadowing.py` gate ([#819](https://github.com/aallan/vera/issues/819)): reject-set membership (opaque built-ins in, overridable combinators out), top-level + `where`-block `fn ` definitions flagged, non-built-in / overridable / prose-mention ignored, and the shipped docs are currently clean |
-| `test_runtime_traps.py` | 75 | 2,877 | Runtime trap categorisation (#516 Stage 1), out-of-bounds host-read bounds check (#1145), stdout/stderr-on-trap preservation (#522), `IO.print` live tee (#543), and trap source backtrace (#516 Stage 2): `_classify_trap` per-`kind` mapping (`divide_by_zero`/`out_of_bounds`/`stack_exhausted`/`unreachable`/`overflow`/`contract_violation`/`unknown`), `WasmTrapError` shape + `RuntimeError` substitutability, end-to-end `cmd_run` text + JSON envelopes including `trap_kind`, captured `stdout`, captured `stderr`, JSON-mode "no stderr leak" invariant, cross-stream code-order regression using merged `redirect_stdout`/`redirect_stderr`, the v0.0.123 tee suite (live streaming, write-count + order preservation, JSON-mode tee suppression, trap preservation invariant under tee, per-write flush count, default-execute silence), and the v0.0.124 source-mapping suite — `_resolve_trap_frames` unit tests covering user-fn / built-in / built-in-prefix / monomorphized base-name fallback / unknown-name / no-frames-attribute / leaf-first ordering preservation; end-to-end `cmd_run` text-mode + JSON-mode backtrace including the **leaf-first** ordering invariant; contract-violation backtrace in both text and JSON modes; direct `execute()` `WasmTrapError.frames` attachment; **suppression marker** for collapsed leading runtime-helper frames (mocked `vera.codegen.execute` with synthetic `is_builtin=True` leaf frames so the collapse logic is testable deterministically); source-map population for top-level fns + lifted closures (with span-value assertion against the closure literal's exact line range); and the no-builtin-leakage regression that pins built-in helpers (`alloc` / `gc_collect` / `contract_fail`) NOT being registered in `fn_source_map`; plus the v0.0.125 Stage 3 suite (`#547`) — text-mode `Fix:` block surfacing with position-ordering invariant (Fix appears after the source backtrace), text-mode block suppression for `contract_violation` (no empty header noise), JSON-mode `fix` field always-present (schema stability) including the empty-string case, `_TRAP_FIX_PARAGRAPHS` table-completeness assertion (every kind in the taxonomy has a Fix paragraph entry), and the column-wrap invariant (~76 chars max per line, two-space indent under the `Fix:` heading); plus the UTF-8 hardening suite **`TestHostPrintInvalidUtf8589`** (`#589` / `#592`) — after `#592` centralised the `errors="replace"` invariant into the single `vera.runtime.text.safe_utf8_decode` helper — reached only through a shared `_slice_and_decode` helper (`vera/runtime/heap.py`) that the three WASM-memory string readers (`_read_wasm_string` and `_read_string_export` there, and `vera/wasm/markdown.py::_read_string`) delegate to, with the `host_print` / `host_stderr` / `host_contract_fail` host imports and the String-return extractor in `execute()` routing through those readers rather than decoding inline: one helper unit test pinning the invariant once (invalid bytes → U+FFFD, valid + empty pass through), three wire-real end-to-end tests that drive the **production** readers (`_read_wasm_string` / markdown `_read_string` behind a synthetic-WAT `probe` host import; `_read_string_export` against a real exported memory, also covering its out-of-bounds → `None` pointer-fallback) over a region seeded with invalid UTF-8 — so a strict-decode regression surfaces as a `UnicodeDecodeError` escaping wasmtime's trampoline, and the host imports / extractor are transitively covered — and one synthetic-WAT end-to-end test that imports `vera.print` and calls it with raw invalid UTF-8 bytes to pin the wasmtime-trampoline fact independently (a Python `UnicodeDecodeError` inside a host import escapes as a "python exception" cause iff the host decode is strict); the six pre-`#592` structural source-grep assertions were retired by the centralisation; plus the Ctrl-C-during-host-import suite **`TestHostSleepKeyboardInterrupt`** ([#595](https://github.com/aallan/vera/issues/595) / [#599](https://github.com/aallan/vera/issues/599)) — after the v0.0.160 relocation to a single `except KeyboardInterrupt` handler in `execute()` (enabled by `wasmtime>=45.0.0`'s `except BaseException` trampoline fix): one structural assertion that the four per-host-import `raise _VeraExit(130)` guards are gone and the centralized handler maps to `exit_code=130`, plus four end-to-end tests that compile real Vera programs calling `IO.sleep(...)`, `IO.read_char(())`, a mocked fused `await` ([#841](https://github.com/aallan/vera/issues/841) — `Future.result()` patched to interrupt), and a live in-flight fused `await` (no mocking — `_thread.interrupt_main()` fired only once the server confirms the request arrived, handler then released so the executor teardown has a real worker to wait out; post-[#848](https://github.com/aallan/vera/issues/848) the progress print precedes the `async(...)`, so program order makes its stdout assertion deterministic), raise `KeyboardInterrupt` from inside the blocking call, and assert the program exits with `ExecuteResult.exit_code == 130` (pre-interrupt stdout preserved) instead of a raw Python traceback escaping wasmtime's trampoline |
+| `test_runtime_traps.py` | 93 | 3,156 | Runtime trap categorisation (#516 Stage 1), out-of-bounds host-read bounds check (#1145), stdout/stderr-on-trap preservation (#522), `IO.print` live tee (#543), and trap source backtrace (#516 Stage 2): `_classify_trap` per-`kind` mapping (`divide_by_zero`/`out_of_bounds`/`stack_exhausted`/`unreachable`/`overflow`/`contract_violation`/`host_error`/`unknown`), `WasmTrapError` shape + `RuntimeError` substitutability, end-to-end `cmd_run` text + JSON envelopes including `trap_kind`, captured `stdout`, captured `stderr`, JSON-mode "no stderr leak" invariant, cross-stream code-order regression using merged `redirect_stdout`/`redirect_stderr`, the v0.0.123 tee suite (live streaming, write-count + order preservation, JSON-mode tee suppression, trap preservation invariant under tee, per-write flush count, default-execute silence), and the v0.0.124 source-mapping suite — `_resolve_trap_frames` unit tests covering user-fn / built-in / built-in-prefix / monomorphized base-name fallback / unknown-name / no-frames-attribute / leaf-first ordering preservation; end-to-end `cmd_run` text-mode + JSON-mode backtrace including the **leaf-first** ordering invariant; contract-violation backtrace in both text and JSON modes; direct `execute()` `WasmTrapError.frames` attachment; **suppression marker** for collapsed leading runtime-helper frames (mocked `vera.codegen.execute` with synthetic `is_builtin=True` leaf frames so the collapse logic is testable deterministically); source-map population for top-level fns + lifted closures (with span-value assertion against the closure literal's exact line range); and the no-builtin-leakage regression that pins built-in helpers (`alloc` / `gc_collect` / `contract_fail`) NOT being registered in `fn_source_map`; plus the v0.0.125 Stage 3 suite (`#547`) — text-mode `Fix:` block surfacing with position-ordering invariant (Fix appears after the source backtrace), text-mode block suppression for `contract_violation` (no empty header noise), JSON-mode `fix` field always-present (schema stability) including the empty-string case, `_TRAP_FIX_PARAGRAPHS` table-completeness assertion (every kind in the taxonomy has a Fix paragraph entry), and the column-wrap invariant (~76 chars max per line, two-space indent under the `Fix:` heading); plus the UTF-8 hardening suite **`TestHostPrintInvalidUtf8589`** (`#589` / `#592`) — after `#592` centralised the `errors="replace"` invariant into the single `vera.runtime.text.safe_utf8_decode` helper — reached only through a shared `_slice_and_decode` helper (`vera/runtime/heap.py`) that the three WASM-memory string readers (`_read_wasm_string` and `_read_string_export` there, and `vera/wasm/markdown.py::_read_string`) delegate to, with the `host_print` / `host_stderr` / `host_contract_fail` host imports and the String-return extractor in `execute()` routing through those readers rather than decoding inline: one helper unit test pinning the invariant once (invalid bytes → U+FFFD, valid + empty pass through), three wire-real end-to-end tests that drive the **production** readers (`_read_wasm_string` / markdown `_read_string` behind a synthetic-WAT `probe` host import; `_read_string_export` against a real exported memory, also covering its out-of-bounds → `None` pointer-fallback) over a region seeded with invalid UTF-8 — so a strict-decode regression surfaces as a `UnicodeDecodeError` escaping wasmtime's trampoline, and the host imports / extractor are transitively covered — and one synthetic-WAT end-to-end test that imports `vera.print` and calls it with raw invalid UTF-8 bytes to pin the wasmtime-trampoline fact independently (a Python `UnicodeDecodeError` inside a host import escapes as a "python exception" cause iff the host decode is strict); the six pre-`#592` structural source-grep assertions were retired by the centralisation; plus the Ctrl-C-during-host-import suite **`TestHostSleepKeyboardInterrupt`** ([#595](https://github.com/aallan/vera/issues/595) / [#599](https://github.com/aallan/vera/issues/599)) — after the v0.0.160 relocation to a single `except KeyboardInterrupt` handler in `execute()` (enabled by `wasmtime>=45.0.0`'s `except BaseException` trampoline fix): one structural assertion that the four per-host-import `raise _VeraExit(130)` guards are gone and the centralized handler maps to `exit_code=130`, plus four end-to-end tests that compile real Vera programs calling `IO.sleep(...)`, `IO.read_char(())`, a mocked fused `await` ([#841](https://github.com/aallan/vera/issues/841) — `Future.result()` patched to interrupt), and a live in-flight fused `await` (no mocking — `_thread.interrupt_main()` fired only once the server confirms the request arrived, handler then released so the executor teardown has a real worker to wait out; post-[#848](https://github.com/aallan/vera/issues/848) the progress print precedes the `async(...)`, so program order makes its stdout assertion deterministic), raise `KeyboardInterrupt` from inside the blocking call, and assert the program exits with `ExecuteResult.exit_code == 130` (pre-interrupt stdout preserved) instead of a raw Python traceback escaping wasmtime's trampoline; plus the host-callback surface suite **`TestHostCallbackErrorSurface1302`** / **`TestClassifyHostError1302`** ([#1302](https://github.com/aallan/vera/issues/1302)) — `execute()` classified on the exception's TYPE NAME (`Trap` / `WasmtimeError`), so a host import raising an ordinary Python exception skipped the conversion and escaped as a 63-line interpreter traceback with the captured streams dropped, and in `--json` mode with no envelope emitted at all.  The conversion is now keyed on the BOUNDARY (everything escaping the guest invocation), and the suite drives a real `json_stringify(JNumber(nan()))` program through all three surfaces: `execute()` raising a `WasmTrapError` of `kind="host_error"` carrying the host's sentence, the pre-failure stdout and the original exception as `__cause__`; text-mode `cmd_run` asserted on the ABSENCE of `Traceback` / `File "` / `wasmtime` and a sub-ten-line diagnostic, since asserting only that the sentence appears would still pass on the pre-fix output where it was the traceback's last line; and JSON-mode `cmd_run` producing a parseable envelope with `trap_kind`, an always-present empty `fix`, `frames`, and the captured `stdout`.  **`TestHostErrorDebugKnob1302`** covers the escape hatch the conversion needs — `VERA_DEBUG_HOST_ERRORS` (ENVIRONMENT.md) re-raises the original exception so a binding bug stays diagnosable — as a deliberate pair, one test proving the knob does something and one proving its absence is what produces the one-liner, since neither alone distinguishes a working knob from unconditional behaviour, plus the truthiness table shared with `VERA_EAGER_GC` and an end-to-end `cmd_run` case |
 | `test_serve.py` | 8 | 189 | #305 `vera serve` driver end-to-end: GET/POST echo round-trips (method/path/headers/body cross the host↔guest boundary via `build_request_adt` / `decode_response_adt`), handler status propagation, runtime contract violation → 500 with `trap_kind` JSON, `State` isolation across requests (instance-per-request pinned), and clean `make_server` validation errors (missing / wrong-signature `handle`), and an eager-GC round-trip pinning the Request builder's shadow-rooting; all on ephemeral ports |
 | `test_wasi_target.py` | 270 | 2,142 | #237 WASI Preview 2 target (spec chapter 13): component emission validated live against the real wasmtime host — parse (`Component(engine, wat)`), instantiate (`Linker.add_wasip2()` + `WasiConfig`), and execute (stdout/stderr capture, env, argv incl. a 500-arg GC-pressure stress and a >64 KiB arena-cap trap, preopen file round-trips + errno mapping, stdin incl. UTF-8 multibyte, clocks, random bounds, exit, contract-violation text on WASI stderr, overflow); the family gate (clean diagnostic naming unsupported families, never a silent fallback); the core-emission pin (default `--target wasm` WAT untouched); `cmd_compile`/`cmd_run --target wasi-p2` CLI integration (binary component artifact, `--wat` component text, JSON envelopes, trap-kind classification through the component boundary, exit-code 0/1 degradation, `--fn` rejection); the `execute_wasi_p2` host runner (env passthrough, argv, stderr capture, String-main `wasi:cli/run` fallback); the **dual-target conformance differential** (all 169 run-level conformance programs driven under both targets, byte-identical stdout/stderr required — 119 are dual-tested and 50 skip *loudly* rather than passing silently: 43 whose compiled WAT imports a host family outside `IO`/`Random` (`state`, `map`, `json`, `set`, `decimal`, `html`, `md`, `regex`, `db`), 6 with no public zero-argument `main`, and 1 calling a nondeterministic op.  The excluded set is defined by those three properties rather than by a filename list, so it stays accurate as programs are added); a stock-`wasmtime`-CLI smoke test (skips when the CLI is not installed); and the Stage-D **server world** (`world="server"`): incoming-handler emission pins (adapter lift, 32-slot dispatch table, @0.2.0 version pin, no `wasi:cli/run`), #305 handler validation + server family-gate diagnostics (rejected IO ops, non-String map instantiations, unsupported families), the cli-world pin (default emission carries no server machinery), Request/Response layout tripwires, and a stock-`wasmtime serve` smoke battery (host-vs-served differential over a method/path/header/body matrix incl. duplicate-header later-wins, in-guest map-op order parity, `IO.print` console routing, trap→500 with symbolized backtrace + violation text, graceful 500s for forbidden headers and out-of-range status, a 1 MiB GC-stress echo, and an eager-GC shadow-push mutation validation; skips when the CLI is not installed) |
 
diff --git a/docs/SKILL.md b/docs/SKILL.md
index 0549310d..1a90cb97 100644
--- a/docs/SKILL.md
+++ b/docs/SKILL.md
@@ -921,6 +921,13 @@ decimal_to_float(@Decimal.0)                        -- returns Float64 (potentia
 
 The `Json` type has six constructors: `JNull`, `JBool(Bool)`, `JNumber(Float64)`, `JString(String)`, `JArray(Array)`, `JObject(Map)`. It is provided by the standard prelude — no `data` declaration needed.
 
+**What `json_parse` accepts.** Exactly RFC 8259-valid text that decodes to finite numbers and strings of Unicode scalar values (spec §9.7.1). Anything else is `Err`, at the parse, with the same message on the CLI and in the browser — Vera defines this domain rather than inheriting whichever one the host parser implements. Two consequences are worth knowing before you write the `Err` arm:
+
+- A **non-finite number** never parses, however it is written. The constants `NaN`, `Infinity` and `-Infinity` are not JSON at all; a number that merely *overflows*, like `1e999`, is syntactically fine and still refused, because what it decodes to is an infinity. Underflow is different — `1e-999` gives you `0` and parses. If a producer you do not control emits any of these, fix the producer or pre-process the text; there is no flag to admit them.
+- A **lone surrogate** escape (`\ud800` with no matching partner) never parses either: its decoded value is not a Unicode scalar, and a Vera `String` is. A *matched* pair is ordinary — `"\ud83d\ude00"` parses fine and gives you the astral character.
+
+Everything else that fails is a plain syntax error, and that message is the host parser's own.
+
 ```vera
 json_parse("{\"name\":\"Vera\"}")               -- returns Result
 json_stringify(@Json.0)                          -- returns String (JSON text)
@@ -2436,7 +2443,7 @@ Current reference-implementation bugs that an agent writing Vera code is likely
 |---|---|---|---|
 | Rare conformance-gate flake | `ch05_closure_nat_return` trapped once in a full conformance run and never again (~960 clean attempts) — suspected runtime/GC timing interaction, not a compiler defect. | If CI reds on this program with `Reached unreachable` in `main`, re-run and report on the issue with wasmtime version + load conditions — do not chase the compiler. | [#996](https://github.com/aallan/vera/issues/996) |
 
-When a Vera program type-checks cleanly, compiles without errors, and then produces a runtime trap you can't explain, runtime trap diagnostics are now Vera-native end-to-end: each trap carries a `kind` label (`divide_by_zero` / `out_of_bounds` / `stack_exhausted` / `unreachable` / `overflow` / `contract_violation` / `unknown`), a per-kind `Fix:` paragraph naming the canonical remediation, and a source backtrace pointing at the offending Vera function and line — not just `wasm trap: `.  Tail-recursive iteration runs in constant WASM stack space for both non-allocating ([#517](https://github.com/aallan/vera/issues/517), v0.0.126) and allocating ([#549](https://github.com/aallan/vera/issues/549), v0.0.154) tail calls — the latter prepends a `$gc_sp` restore before each `return_call` to keep the shadow stack bounded across iterations.
+When a Vera program type-checks cleanly, compiles without errors, and then produces a runtime trap you can't explain, runtime trap diagnostics are now Vera-native end-to-end: each trap carries a `kind` label (`divide_by_zero` / `out_of_bounds` / `stack_exhausted` / `unreachable` / `overflow` / `contract_violation` / `host_error` / `unknown`), a per-kind `Fix:` paragraph naming the canonical remediation, and a source backtrace pointing at the offending Vera function and line — not just `wasm trap: `.  Tail-recursive iteration runs in constant WASM stack space for both non-allocating ([#517](https://github.com/aallan/vera/issues/517), v0.0.126) and allocating ([#549](https://github.com/aallan/vera/issues/549), v0.0.154) tail calls — the latter prepends a `$gc_sp` restore before each `return_call` to keep the shadow stack bounded across iterations.
 
 ## Specification Reference
 
diff --git a/docs/index.html b/docs/index.html
index 8f9c5705..9e549251 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -576,7 +576,7 @@ 

Browser

runtime.mjs index.html
-

Self-contained — no bundler. Serve with any HTTP server (python -m http.server). IO.print writes to the page; every other operation the browser target supports works identically to the CLI, apart from md_parse, whose two hand-written implementations still disagree on a few shapes the §9.7.3 subset does not pin (#1301). json_stringify and md_render reach that identity by emitting a canonical form the specification states (§9.7.1, §9.7.3) rather than by the hosts happening to agree, so parity tests check them against that form as well as against each other, on every PR. Note: Inference.complete and every DB operation return an error in the browser — a deliberate platform boundary, since the credentials they need would be readable from page source; reach them through a server-side proxy via Http.

+

Self-contained — no bundler. Serve with any HTTP server (python -m http.server). IO.print writes to the page; every other operation the browser target supports works identically to the CLI, apart from md_parse, whose two hand-written implementations still disagree on a few shapes the §9.7.3 subset does not pin (#1301). json_stringify and md_render reach that identity by emitting a canonical form the specification states (§9.7.1, §9.7.3) rather than by the hosts happening to agree, and json_parse by accepting the domain §9.7.1 states — RFC 8259-valid text that decodes to finite numbers and strings of Unicode scalar values — rather than whatever its host parser admits; parity tests check all three against that stated form as well as against each other, on every PR. Note: Inference.complete and every DB operation return an error in the browser — a deliberate platform boundary, since the credentials they need would be readable from page source; reach them through a server-side proxy via Http.

WASI components

diff --git a/docs/index.md b/docs/index.md index 7e088e31..0c86625f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -217,7 +217,7 @@ Browser bundle: examples/hello_world_browser/ index.html ``` -Self-contained — no bundler. Serve with any HTTP server (`python -m http.server`). `IO.print` writes to the page; every other operation the browser target supports works identically to the CLI, apart from `md_parse`, whose two hand-written implementations still disagree on a few shapes the §9.7.3 subset does not pin ([#1301](https://github.com/aallan/vera/issues/1301)). `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states (§9.7.1, §9.7.3) rather than by the hosts happening to agree, so parity tests check them against that form as well as against each other, on every PR. *Note: `Inference.complete` and every `DB` operation return an error in the browser — a deliberate platform boundary, since the credentials they need would be readable from page source; reach them through a server-side proxy via `Http`.* +Self-contained — no bundler. Serve with any HTTP server (`python -m http.server`). `IO.print` writes to the page; every other operation the browser target supports works identically to the CLI, apart from `md_parse`, whose two hand-written implementations still disagree on a few shapes the §9.7.3 subset does not pin ([#1301](https://github.com/aallan/vera/issues/1301)). `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states (§9.7.1, §9.7.3) rather than by the hosts happening to agree, and `json_parse` by accepting the domain §9.7.1 states — RFC 8259-valid text that decodes to finite numbers and strings of Unicode scalar values — rather than whatever its host parser admits; parity tests check all three against that stated form as well as against each other, on every PR. *Note: `Inference.complete` and every `DB` operation return an error in the browser — a deliberate platform boundary, since the credentials they need would be readable from page source; reach them through a server-side proxy via `Http`.* ### WASI components diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 80407308..99669e8d 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -927,6 +927,13 @@ decimal_to_float(@Decimal.0) -- returns Float64 (potentia The `Json` type has six constructors: `JNull`, `JBool(Bool)`, `JNumber(Float64)`, `JString(String)`, `JArray(Array)`, `JObject(Map)`. It is provided by the standard prelude — no `data` declaration needed. +**What `json_parse` accepts.** Exactly RFC 8259-valid text that decodes to finite numbers and strings of Unicode scalar values (spec §9.7.1). Anything else is `Err`, at the parse, with the same message on the CLI and in the browser — Vera defines this domain rather than inheriting whichever one the host parser implements. Two consequences are worth knowing before you write the `Err` arm: + +- A **non-finite number** never parses, however it is written. The constants `NaN`, `Infinity` and `-Infinity` are not JSON at all; a number that merely *overflows*, like `1e999`, is syntactically fine and still refused, because what it decodes to is an infinity. Underflow is different — `1e-999` gives you `0` and parses. If a producer you do not control emits any of these, fix the producer or pre-process the text; there is no flag to admit them. +- A **lone surrogate** escape (`\ud800` with no matching partner) never parses either: its decoded value is not a Unicode scalar, and a Vera `String` is. A *matched* pair is ordinary — `"\ud83d\ude00"` parses fine and gives you the astral character. + +Everything else that fails is a plain syntax error, and that message is the host parser's own. + ```vera json_parse("{\"name\":\"Vera\"}") -- returns Result json_stringify(@Json.0) -- returns String (JSON text) @@ -2442,7 +2449,7 @@ Current reference-implementation bugs that an agent writing Vera code is likely |---|---|---|---| | Rare conformance-gate flake | `ch05_closure_nat_return` trapped once in a full conformance run and never again (~960 clean attempts) — suspected runtime/GC timing interaction, not a compiler defect. | If CI reds on this program with `Reached unreachable` in `main`, re-run and report on the issue with wasmtime version + load conditions — do not chase the compiler. | [#996](https://github.com/aallan/vera/issues/996) | -When a Vera program type-checks cleanly, compiles without errors, and then produces a runtime trap you can't explain, runtime trap diagnostics are now Vera-native end-to-end: each trap carries a `kind` label (`divide_by_zero` / `out_of_bounds` / `stack_exhausted` / `unreachable` / `overflow` / `contract_violation` / `unknown`), a per-kind `Fix:` paragraph naming the canonical remediation, and a source backtrace pointing at the offending Vera function and line — not just `wasm trap: `. Tail-recursive iteration runs in constant WASM stack space for both non-allocating ([#517](https://github.com/aallan/vera/issues/517), v0.0.126) and allocating ([#549](https://github.com/aallan/vera/issues/549), v0.0.154) tail calls — the latter prepends a `$gc_sp` restore before each `return_call` to keep the shadow stack bounded across iterations. +When a Vera program type-checks cleanly, compiles without errors, and then produces a runtime trap you can't explain, runtime trap diagnostics are now Vera-native end-to-end: each trap carries a `kind` label (`divide_by_zero` / `out_of_bounds` / `stack_exhausted` / `unreachable` / `overflow` / `contract_violation` / `host_error` / `unknown`), a per-kind `Fix:` paragraph naming the canonical remediation, and a source backtrace pointing at the offending Vera function and line — not just `wasm trap: `. Tail-recursive iteration runs in constant WASM stack space for both non-allocating ([#517](https://github.com/aallan/vera/issues/517), v0.0.126) and allocating ([#549](https://github.com/aallan/vera/issues/549), v0.0.154) tail calls — the latter prepends a `$gc_sp` restore before each `return_call` to keep the shadow stack bounded across iterations. ## Specification Reference @@ -3149,7 +3156,7 @@ vera compile --target browser examples/hello_world.vera # index.html ``` -Serve it with any HTTP server and open `index.html` — no build step, no bundler, no dependencies. The JavaScript runtime provides browser-appropriate implementations of the host bindings the browser target supports — the ones a page can host, which leaves a filesystem, an accept loop, a database and a model provider outside it by construction (spec §12.9.3 lists each and why): `IO.print` writes to the page, `IO.read_line` uses `prompt()`, and State, contracts, JSON serialization and Markdown rendering work identically to the wasmtime runtime. `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states — §9.7.1 and §9.7.3 — rather than by the two hosts happening to agree, which is what the parity suite checks them against. The two *parsers* are where the runtimes still differ, and each difference is tracked. `json_parse` disagrees on inputs outside RFC 8259: the reference host accepts `NaN` and the infinities where the browser refuses them ([#1306](https://github.com/aallan/vera/issues/1306)), and a `\uD800` escape becomes U+FFFD in the browser where the reference host fails outright ([#1308](https://github.com/aallan/vera/issues/1308)) — well-formed JSON parses the same on both. `md_parse` differs more widely: the two hand-written parsers disagree across nine measured classes of input the §9.7.3 subset leaves open, the largest by a wide margin being how a paragraph's plain-text runs are grouped — invisible to `md_render`, since the runs concatenate to the same text — and the rest render-visible, from how emphasis markers are scanned to block markers such as a `+` bullet or a list nested more than two deep. That one is tracked as [#1301](https://github.com/aallan/vera/issues/1301). +Serve it with any HTTP server and open `index.html` — no build step, no bundler, no dependencies. The JavaScript runtime provides browser-appropriate implementations of the host bindings the browser target supports — the ones a page can host, which leaves a filesystem, an accept loop, a database and a model provider outside it by construction (spec §12.9.3 lists each and why): `IO.print` writes to the page, `IO.read_line` uses `prompt()`, and State, contracts, JSON serialization and Markdown rendering work identically to the wasmtime runtime. `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states — §9.7.1 and §9.7.3 — rather than by the two hosts happening to agree, which is what the parity suite checks them against. `json_parse` reaches it from the other side, by accepted domain rather than by output form: §9.7.1 states what it takes — RFC 8259-valid text that decodes to finite numbers and strings of Unicode scalar values — so the JavaScript constants (`NaN`, `Infinity`, `-Infinity`) and a lone-surrogate escape are `Err` at the parse on both hosts, with one message, and every text inside the domain parses identically. `md_parse` is the one operation on the shared surface still to reach parity: the two hand-written parsers disagree across nine measured classes of input the §9.7.3 subset leaves open, the largest by a wide margin being how a paragraph's plain-text runs are grouped — invisible to `md_render`, since the runs concatenate to the same text — and the rest render-visible, from how emphasis markers are scanned to block markers such as a `+` bullet or a list nested more than two deep. That one is tracked as [#1301](https://github.com/aallan/vera/issues/1301). Two effects are refused outright rather than merely differing. `Inference` and `DB` return an explanatory `Err` from every operation in the browser, because the API key or database credential they would need is readable from page source and network traffic in client-side JavaScript. Reach them through a server-side endpoint and call it with `Http`, which does run in the browser — it is backed by `XMLHttpRequest`, not a stub. That refusal is a deliberate platform boundary; spec §9.5.5 states it for `Inference`. diff --git a/examples/json.vera b/examples/json.vera index bc795e48..5c6044a3 100644 --- a/examples/json.vera +++ b/examples/json.vera @@ -76,6 +76,12 @@ private fn current_temp(@Json -> @Option) } -- Parse the weather response and return the current temperature in Celsius. +-- +-- json_parse accepts exactly RFC 8259-valid text whose decoded strings are +-- Unicode scalar sequences (spec 9.7.1), and the same texts on every host. +-- So the Err arm below is where malformed text lands, and also where the +-- JavaScript constants (NaN, Infinity, -Infinity) and a lone-surrogate +-- escape land -- all at the parse, never at a later call. private fn parse_current_temp(@String -> @Result) requires(true) ensures(true) diff --git a/scripts/build_site.py b/scripts/build_site.py index 5164c9e3..45a123ae 100644 --- a/scripts/build_site.py +++ b/scripts/build_site.py @@ -615,7 +615,7 @@ def build_index_md(version: str) -> str: index.html ``` -Self-contained — no bundler. Serve with any HTTP server (`python -m http.server`). `IO.print` writes to the page; every other operation the browser target supports works identically to the CLI, apart from `md_parse`, whose two hand-written implementations still disagree on a few shapes the §9.7.3 subset does not pin ([#1301](https://github.com/aallan/vera/issues/1301)). `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states (§9.7.1, §9.7.3) rather than by the hosts happening to agree, so parity tests check them against that form as well as against each other, on every PR. *Note: `Inference.complete` and every `DB` operation return an error in the browser — a deliberate platform boundary, since the credentials they need would be readable from page source; reach them through a server-side proxy via `Http`.* +Self-contained — no bundler. Serve with any HTTP server (`python -m http.server`). `IO.print` writes to the page; every other operation the browser target supports works identically to the CLI, apart from `md_parse`, whose two hand-written implementations still disagree on a few shapes the §9.7.3 subset does not pin ([#1301](https://github.com/aallan/vera/issues/1301)). `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states (§9.7.1, §9.7.3) rather than by the hosts happening to agree, and `json_parse` by accepting the domain §9.7.1 states — RFC 8259-valid text that decodes to finite numbers and strings of Unicode scalar values — rather than whatever its host parser admits; parity tests check all three against that stated form as well as against each other, on every PR. *Note: `Inference.complete` and every `DB` operation return an error in the browser — a deliberate platform boundary, since the credentials they need would be readable from page source; reach them through a server-side proxy via `Http`.* ### WASI components diff --git a/spec/09-standard-library.md b/spec/09-standard-library.md index c8d86ed5..58ca43a3 100644 --- a/spec/09-standard-library.md +++ b/spec/09-standard-library.md @@ -2075,9 +2075,16 @@ The `Json` type is provided by the standard prelude — no explicit `data` decla | Function | Signature | Description | |----------|-----------|-------------| -| `json_parse(s)` | `(String) → Result` | Parse a JSON string; `Err` on invalid input | +| `json_parse(s)` | `(String) → Result` | Parse JSON text; `Err` outside the accepted domain below | | `json_stringify(j)` | `(Json) → String` | Serialize a Json value to its canonical JSON string | +**`json_parse`'s accepted domain.** Both runtimes accept exactly RFC 8259-valid text that decodes to finite numbers and to strings that are sequences of Unicode scalar values. Text outside that domain MUST produce `Err` at the parse rather than a value, and for the two exclusions below the message MUST be the same on every runtime. The domain is defined here rather than inherited from whatever the host parser happens to accept (DESIGN.md: explicit over implicit) — the two exclusions are precisely where the host parsers disagree: + +- **A non-finite number, however it is written.** RFC 8259 has no literal for one, so the JavaScript constants `NaN`, `Infinity` and `-Infinity` are refused wherever a value may appear, nested or at the top level. A *syntactically valid* number whose magnitude overflows the `Float64` a `JNumber` holds is refused too, whether it is written with an exponent (`1e999`) or as plain digits (`1` followed by 309 zeros) — the distinction matters to a host whose parser decodes the two to different types, and not at all to the domain: RFC 8259 §6 sets no limit on a number's range but says an implementation may set one, and Vera's accepted range is the finite `Float64` values, which is exactly what `json_stringify` can write back. The bound is the point at which the nearest `Float64` becomes infinite, so a magnitude above the largest finite double that still *rounds* to it is accepted. Underflow is a different question and is *not* refused either: `1e-999` decodes to `0`, which is finite and in the domain. This is the input-side counterpart of the serialization rule below — a non-finite number has no JSON representation in either direction — and with both entry routes closed, the only way to reach the output-side refusal is to construct a `JNumber` from `nan()` or `infinity()`. +- **A lone surrogate** — a `\uXXXX` escape in D800–DFFF with no matching partner. The text is grammatically legal, but its decoded value is not a sequence of scalar values, and a Vera `String` is. The alternatives are substituting U+FFFD, which silently yields a value the text did not encode, and admitting strings the rest of the language cannot represent (§0.2.6). A *matched* high-then-low pair is ordinary and is accepted: it denotes one astral scalar value. + +Text malformed for any other reason also produces `Err`, but that message is the host parser's own and is not pinned. + **Canonical serialization.** `json_stringify` has exactly one output form, per the one-canonical-form principle (§0.2.3), and every runtime produces it byte for byte (§12.9.3): - **Separators.** `,` between elements and members, `:` between a key and its value, with no surrounding whitespace. No indentation, no trailing newline. diff --git a/spec/12-runtime.md b/spec/12-runtime.md index ce820b56..42ca5966 100644 --- a/spec/12-runtime.md +++ b/spec/12-runtime.md @@ -660,7 +660,7 @@ The rows above fall into three kinds, and the distinction matters when reading a Across the surface the two runtimes actually share — State, contracts, JSON, `md_render` and the rest of the non-IO operations — results are identical, with the single exception `md_parse` records below. The boundary rows are outside that claim by construction, having no browser counterpart to agree with, and an `Http` call's outcome is host-specific for a milder reason: the browser issues it through synchronous `XMLHttpRequest`, so a JavaScript host without one returns an explanatory `Err` where the reference runtime performs the request. -Two operations reach that identity by carrying a canonical form the specification states rather than by both hosts happening to agree. `json_stringify` emits the one form §9.7.1 pins, down to separators and number rendering, and refuses a non-finite number on both hosts instead of one refusing and the other substituting `null`. `md_render` emits the canonical Markdown §9.7.3 describes — soft line breaks collapsed, each container's prefix re-applied to every line of every child, children separated, code spans fenced wider than their content — which is what makes the render a fixed point on both hosts, outside the three code-span shapes §9.7.3 records as unwriteable in the subset, which both hosts lose identically rather than differently. +Two operations reach that identity by carrying a canonical form the specification states rather than by both hosts happening to agree. `json_stringify` emits the one form §9.7.1 pins, down to separators and number rendering, and refuses a non-finite number on both hosts instead of one refusing and the other substituting `null`. `json_parse` reaches the same identity from the other side: §9.7.1 states the accepted domain — RFC 8259-valid text that decodes to finite numbers and strings of Unicode scalar values — so both hosts refuse the JavaScript constants and a lone-surrogate escape at the parse, with one message, rather than each inheriting whatever its own parser admits. `md_render` emits the canonical Markdown §9.7.3 describes — soft line breaks collapsed, each container's prefix re-applied to every line of every child, children separated, code spans fenced wider than their content — which is what makes the render a fixed point on both hosts, outside the three code-span shapes §9.7.3 records as unwriteable in the subset, which both hosts lose identically rather than differently. `md_parse` is the one operation on the shared surface where the requirement is not yet met. The two implementations are hand-written parsers for the §9.7.3 subset, and they still disagree on inputs the subset does not pin. One class is invisible to `md_render`: how adjacent plain-text runs are grouped inside a paragraph — the largest by count, and hidden at the render level because the runs concatenate to the same text. The rest are visible in the rendered output. Two are inline: how emphasis and strong markers are scanned when they nest or go unclosed, and how much indentation a continuation line loses inside a list item (the reference strips a fixed two or three characters, the browser all of it). The others are block markers — a `+` bullet, an `n)` ordered marker, a list item separated from the next by a blank line, a list nested more than two deep, a thematic break written with internal spaces, and a table without a separator row. The divergence is a tracked bug ([#1301](https://github.com/aallan/vera/issues/1301)), not part of the browser target's definition; the parity suite covers the shapes they do agree on, so a regression on one of those goes red. @@ -691,6 +691,6 @@ The `index.html` file uses an ES module script that imports from `vera-runtime.m The browser parity test suite (`tests/test_browser.py`) runs the examples the browser target can execute — and per-binding batteries over the Map, Set, Decimal, Json, Regex and Markdown host imports — through both the Python/wasmtime runtime and the Node.js/JS-runtime. The example corpus is two explicit lists in that file rather than the whole `examples/` directory, and the two carry different oracles: the examples exporting `main` are run and compared on stdout, while the ones reached as exported functions are called with fixed arguments and compared on the returned value. The per-binding batteries compare stdout. An example is excluded from the corpus when it reads stdin interactively, when it uses a host family the browser refuses (file IO, `DB`), or when it does not compile standalone, and each exclusion is recorded beside the list with its reason. This catches drift between the two implementations across the shared surface, save for the `md_parse` shapes §12.9.3 records as already divergent, which the corpus does not carry. The tests cover IO operations, State operations, contract violations, Markdown parsing/rendering, and browser bundle emission. -The two operations that carry a canonical form (§12.9.3) are tested three ways rather than by equality alone, because two hosts agreeing on a wrong answer would satisfy equality: each case asserts cross-host equality, the expected string written out, and — for `md_render` — stability under re-render, which is the observable form of §9.7.3's round-trip property. Some rules are reachable only from an `MdBlock` a program *built* rather than parsed, so the Markdown battery renders constructed values too. `json_stringify`'s number rendering is additionally checked differentially against a real `JSON.stringify` over a sample of doubles drawn from raw bit patterns, since the reference host renders numbers itself rather than delegating. Failures that are contracts rather than values are asserted two-sidedly: a non-finite `JNumber` must make the call fail on both hosts **and** produce no output, so a host that emitted `null` before failing could not read as a pass. The browser stubs are covered on two different shapes: `IO.read_file` and `IO.write_file` get per-host pinning, running both runtimes against a path that is really readable or writable so the native `Ok` and the browser `Err` are each asserted, while `IO.read_char` is checked in the browser alone — that the module links and the stub's `Err` arm is taken — with no native side to compare against. +The two operations that carry a canonical form (§12.9.3) are tested three ways rather than by equality alone, because two hosts agreeing on a wrong answer would satisfy equality: each case asserts cross-host equality, the expected string written out, and — for `md_render` — stability under re-render, which is the observable form of §9.7.3's round-trip property. Some rules are reachable only from an `MdBlock` a program *built* rather than parsed, so the Markdown battery renders constructed values too. `json_stringify`'s number rendering is additionally checked differentially against a real `JSON.stringify` over a sample of doubles drawn from raw bit patterns, since the reference host renders numbers itself rather than delegating. Failures that are contracts rather than values are asserted two-sidedly: a non-finite `JNumber` must make the call fail on both hosts **and** produce no output, so a host that emitted `null` before failing could not read as a pass. `json_parse`'s accepted domain (§9.7.1) is covered by a battery that compares the whole `Err` message across hosts, parameterised over the excluded inputs at every position a string can occupy — value, key, array element, nested — and run beside controls whose acceptance the refusals must not disturb, matched surrogate pairs among them. The browser stubs are covered on two different shapes: `IO.read_file` and `IO.write_file` get per-host pinning, running both runtimes against a path that is really readable or writable so the native `Ok` and the browser `Err` are each asserted, while `IO.read_char` is checked in the browser alone — that the module links and the stub's `Err` arm is taken — with no native side to compare against. Pre-commit hooks trigger parity tests on any change to the host binding surface (`vera/browser/`, `vera/codegen/api.py`, `vera/wasm/markdown.py`, `vera/markdown.py`). CI runs the full parity suite on every PR. diff --git a/tests/conformance/ch09_json_accept_domain.vera b/tests/conformance/ch09_json_accept_domain.vera new file mode 100644 index 00000000..bd2cd444 --- /dev/null +++ b/tests/conformance/ch09_json_accept_domain.vera @@ -0,0 +1,226 @@ +-- Conformance: json_parse's accepted domain (Chapter 9) +-- Tests: json_parse rejects the JavaScript non-finite constants (bare and +-- nested in a container), a number that overflows to an infinity, +-- an integer literal too large for a Float64, and lone-surrogate +-- escapes (value and key positions) at the parse; +-- a matched surrogate pair, "NaN" as an ordinary string value, the +-- largest finite double and an underflow to 0 still parse +-- (#1306, #1308) +-- ================================================================ +-- test_json_parse_nan_rejected +-- json_parse("NaN") — the bare non-finite constant — takes the Err arm. +-- RFC 8259 has no literal for a non-finite number, so no accepted text +-- contains one. +-- ================================================================ +public fn test_json_parse_nan_rejected(@Unit -> @Int) + requires(true) + ensures(@Int.result == 1) + effects(pure) +{ + match json_parse("NaN") { + Ok(_) -> 0, + Err(_) -> 1 + } +} + +-- ================================================================ +-- test_json_parse_infinity_in_array_rejected +-- json_parse("[Infinity]") — a non-finite constant nested inside a +-- container — takes the Err arm too; the refusal is not top-level-only. +-- ================================================================ +public fn test_json_parse_infinity_in_array_rejected(@Unit -> @Int) + requires(true) + ensures(@Int.result == 1) + effects(pure) +{ + match json_parse("[Infinity]") { + Ok(_) -> 0, + Err(_) -> 1 + } +} + +-- ================================================================ +-- test_json_parse_lone_surrogate_value_rejected +-- json_parse of an object whose VALUE contains a lone-surrogate escape +-- (\ud800 with no low-surrogate partner) takes the Err arm: the text is +-- grammatically legal RFC 8259 but its decoded value is not a sequence +-- of Unicode scalar values. +-- ================================================================ +public fn test_json_parse_lone_surrogate_value_rejected(@Unit -> @Int) + requires(true) + ensures(@Int.result == 1) + effects(pure) +{ + match json_parse("{\"k\":\"a\\ud800b\"}") { + Ok(_) -> 0, + Err(_) -> 1 + } +} + +-- ================================================================ +-- test_json_parse_lone_surrogate_key_rejected +-- json_parse of an object whose KEY contains a lone-surrogate escape +-- takes the Err arm too — the refusal applies to keys as well as values. +-- ================================================================ +public fn test_json_parse_lone_surrogate_key_rejected(@Unit -> @Int) + requires(true) + ensures(@Int.result == 1) + effects(pure) +{ + match json_parse("{\"a\\ud800b\":1}") { + Ok(_) -> 0, + Err(_) -> 1 + } +} + +-- ================================================================ +-- test_json_parse_paired_surrogate_accepted +-- Control: a MATCHED high/low surrogate pair denotes one ordinary +-- astral scalar value and must still parse — the boundary the +-- lone-surrogate refusal must not overshoot. Takes the Ok arm; +-- json_type of the result is "object" (length 6). +-- ================================================================ +public fn test_json_parse_paired_surrogate_accepted(@Unit -> @Int) + requires(true) + ensures(@Int.result == 6) + effects(pure) +{ + match json_parse("{\"k\":\"a\\ud83d\\ude00b\"}") { + Ok(@Json) -> string_length(json_type(@Json.0)), + Err(_) -> 0 + } +} + +-- ================================================================ +-- test_json_parse_nan_as_string_value_accepted +-- Control: "NaN" spelled as an ordinary JSON string VALUE is unaffected +-- by the non-finite-constant refusal, which only fires for the bare +-- token. Takes the Ok arm; json_type of the result is "object" +-- (length 6). +-- ================================================================ +public fn test_json_parse_nan_as_string_value_accepted(@Unit -> @Int) + requires(true) + ensures(@Int.result == 6) + effects(pure) +{ + match json_parse("{\"k\":\"NaN\"}") { + Ok(@Json) -> string_length(json_type(@Json.0)), + Err(_) -> 0 + } +} + +-- ================================================================ +-- test_json_parse_overflow_rejected +-- json_parse("1e999") — a syntactically valid RFC 8259 number whose +-- magnitude overflows Float64 — takes the Err arm. RFC 8259 section 6 +-- sets no range limit but lets an implementation set one; Vera's is the +-- finite Float64 values, the second entry route to a non-finite number. +-- ================================================================ +public fn test_json_parse_overflow_rejected(@Unit -> @Int) + requires(true) + ensures(@Int.result == 1) + effects(pure) +{ + match json_parse("1e999") { + Ok(_) -> 0, + Err(_) -> 1 + } +} + +-- ================================================================ +-- test_json_parse_largest_finite_accepted +-- Control: the largest finite double still parses — the boundary the +-- overflow refusal must not overshoot. Takes the Ok arm; json_type of +-- the result is "number" (length 6). +-- ================================================================ +public fn test_json_parse_largest_finite_accepted(@Unit -> @Int) + requires(true) + ensures(@Int.result == 6) + effects(pure) +{ + match json_parse("1.7976931348623157e308") { + Ok(@Json) -> string_length(json_type(@Json.0)), + Err(_) -> 0 + } +} + +-- ================================================================ +-- test_json_parse_underflow_accepted +-- Control: underflow is not overflow. "1e-999" decodes to 0, which is +-- finite and in the domain. Takes the Ok arm; json_type is "number" +-- (length 6). +-- ================================================================ +public fn test_json_parse_underflow_accepted(@Unit -> @Int) + requires(true) + ensures(@Int.result == 6) + effects(pure) +{ + match json_parse("1e-999") { + Ok(@Json) -> string_length(json_type(@Json.0)), + Err(_) -> 0 + } +} + +-- ================================================================ +-- test_json_parse_integer_overflow_rejected +-- json_parse of an INTEGER literal too large for a Float64 takes the Err +-- arm. A digit string with no fraction and no exponent decodes to a +-- Python int on the reference host, which a float-only range check never +-- examined -- and which then had to become an f64 at the WASM boundary. +-- ================================================================ +public fn test_json_parse_integer_overflow_rejected(@Unit -> @Int) + requires(true) + ensures(@Int.result == 1) + effects(pure) +{ + match json_parse("1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000") { + Ok(_) -> 0, + Err(_) -> 1 + } +} + +-- ================================================================ +-- test_json_parse_integer_in_range_accepted +-- Control: one digit fewer rounds into range and still parses -- the +-- boundary the integer refusal must not overshoot. Takes the Ok arm; +-- json_type of the result is "number" (length 6). +-- ================================================================ +public fn test_json_parse_integer_in_range_accepted(@Unit -> @Int) + requires(true) + ensures(@Int.result == 6) + effects(pure) +{ + match json_parse("100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000") { + Ok(@Json) -> string_length(json_type(@Json.0)), + Err(_) -> 0 + } +} + +-- ================================================================ +-- main — run all tests and return 0 on success +-- Expected sum: +-- test_json_parse_nan_rejected = 1 +-- test_json_parse_infinity_in_array_rejected = 1 +-- test_json_parse_lone_surrogate_value_rejected = 1 +-- test_json_parse_lone_surrogate_key_rejected = 1 +-- test_json_parse_paired_surrogate_accepted = 6 +-- test_json_parse_nan_as_string_value_accepted = 6 +-- test_json_parse_overflow_rejected = 1 +-- test_json_parse_largest_finite_accepted = 6 +-- test_json_parse_underflow_accepted = 6 +-- test_json_parse_integer_overflow_rejected = 1 +-- test_json_parse_integer_in_range_accepted = 6 +-- Total: 1+1+1+1+6+6+1+6+6+1+6 = 36 +-- ================================================================ +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + let @Int = test_json_parse_nan_rejected(()) + test_json_parse_infinity_in_array_rejected(()) + test_json_parse_lone_surrogate_value_rejected(()) + test_json_parse_lone_surrogate_key_rejected(()) + test_json_parse_paired_surrogate_accepted(()) + test_json_parse_nan_as_string_value_accepted(()) + test_json_parse_overflow_rejected(()) + test_json_parse_largest_finite_accepted(()) + test_json_parse_underflow_accepted(()) + test_json_parse_integer_overflow_rejected(()) + test_json_parse_integer_in_range_accepted(()); + if @Int.0 == 36 then { + 0 + } else { + 1 + } +} diff --git a/tests/conformance/manifest.json b/tests/conformance/manifest.json index 7a17a1b4..b877b6f7 100644 --- a/tests/conformance/manifest.json +++ b/tests/conformance/manifest.json @@ -1894,6 +1894,24 @@ "json_type" ] }, + { + "id": "ch09_json_accept_domain", + "file": "ch09_json_accept_domain.vera", + "chapter": 9, + "title": "json_parse's accepted domain (#1306, #1308)", + "level": "run", + "spec_ref": "Section 9.7.1", + "features": [ + "json_parse", + "json_accept_domain", + "non_finite_constant_rejected", + "non_finite_overflow_rejected", + "integer_overflow_rejected", + "lone_surrogate_rejected", + "surrogate_pair_accepted", + "finite_boundary_accepted" + ] + }, { "id": "ch09_json_accessors", "file": "ch09_json_accessors.vera", diff --git a/tests/test_browser.py b/tests/test_browser.py index 327b0306..840b38ff 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -20,6 +20,7 @@ import shutil import struct import subprocess +import sys from pathlib import Path from typing import Any @@ -31,7 +32,13 @@ from vera.parser import parse_file from vera.resolver import ModuleResolver from vera.transform import transform -from vera.wasm.json_serde import _non_finite_message, format_json_number +from vera.wasm.json_serde import ( + _lone_surrogate_message, + _non_finite_message, + _non_finite_number_message, + _non_finite_parse_message, + format_json_number, +) # --------------------------------------------------------------------------- # Paths @@ -228,12 +235,15 @@ def _both_failures(src: str, tmp_path: Path, name: str) -> tuple[str, str]: src_path.write_text(src, encoding="utf-8") wasm_path, result = _compile_file(src_path, tmp_path) - # ``execute`` discards its stdout buffer when the exception is not a - # wasmtime trap, so the buffer cannot answer "did it print anything - # first?". ``tee_stdout`` mirrors every ``IO.print`` to - # ``sys.stdout`` as it happens, which survives the unwind — so - # redirecting it gives the native side the same observation the Node - # harness gives for free. + # ``tee_stdout`` mirrors every ``IO.print`` to ``sys.stdout`` as it + # happens, so redirecting it gives the native side the same + # observation the Node harness gives for free: what reached the + # terminal, in real time, before the call failed. Since #1302 + # ``execute`` also carries the buffer on ``WasmTrapError.stdout`` for + # a host-callback failure — it used to discard it — but the tee is + # what this helper wants, because it answers "did anything actually + # get written?" for BOTH failure shapes without the helper having to + # know which one it caught. native_tee = io.StringIO() try: with contextlib.redirect_stdout(native_tee): @@ -3915,6 +3925,384 @@ def test_non_finite_fails_on_both_hosts( assert expected in browser, browser +def _vera_lit(raw: str) -> str: + """Escape ``raw`` for embedding in a Vera string literal. + + The accept-domain battery's inputs are JSON documents full of quotes + and backslashes, and one backslash either way changes which bytes + ``json_parse`` receives — a surrogate escape and a literal + backslash-u sequence differ by exactly that. Converting once at the + boundary is what keeps fifteen call sites honest about their input. + """ + return raw.replace("\\", "\\\\").replace('"', '\\"') + + +def _accept_domain_src(raw_json: str) -> str: + """A ``main`` that reports which arm ``json_parse(raw_json)`` took. + + Prints ``OK:`` or ``ERR:``, so one + byte-identical stdout across the two runtimes covers the arm AND the + message in a single assertion. The reference-host twin of this + probe is in ``tests/test_json_accept_domain_1306_1308.py``. + """ + return f""" +public fn main(@Unit -> @Unit) + requires(true) ensures(true) effects() +{{ + match json_parse("{_vera_lit(raw_json)}") {{ + Ok(@Json) -> IO.print(string_concat("OK:", json_stringify(@Json.0))), + Err(@String) -> IO.print(string_concat("ERR:", @String.0)) + }} +}} +""" + + +# The four probe inputs from #1306's table, plus the container and +# multi-constant shapes that pin how far the refusal reaches and which +# constant names it. +_JSON_NON_FINITE_CASES = [ + ("bare_nan", "NaN", "NaN"), + ("bare_infinity", "Infinity", "Infinity"), + ("bare_negative_infinity", "-Infinity", "-Infinity"), + ("nan_in_array", "[NaN]", "NaN"), + ("infinity_in_object", '{"a":Infinity}', "Infinity"), + ("negative_infinity_in_object", '{"a":-Infinity}', "-Infinity"), + ("first_of_two_wins", "[NaN,Infinity]", "NaN"), +] + +# Positions × escape casings for #1308: keys as well as values, nested +# anywhere, either spelling of the hex digits. +_JSON_LONE_SURROGATE_CASES = [ + ("value_lower", '{"k":"a\\ud800b"}', 0xD800), + ("value_upper", '{"k":"a\\uD800b"}', 0xD800), + ("value_low_surrogate", '{"k":"a\\udc00b"}', 0xDC00), + ("key", '{"a\\ud800b":1}', 0xD800), + ("key_upper", '{"a\\uD800b":1}', 0xD800), + ("array_element", '["a\\ud800b"]', 0xD800), + ("nested_object", '{"o":{"k":"a\\ud800b"}}', 0xD800), + ("nested_array_in_object", '{"o":[1,"a\\ud800b"]}', 0xD800), + ("top_level_string", '"a\\ud800b"', 0xD800), + ("high_then_ascii_escape", '{"k":"\\ud800\\u0041"}', 0xD800), + ("high_then_high", '{"k":"\\ud800\\ud800"}', 0xD800), + ("low_then_valid_pair", '{"k":"\\udc00\\ud83d\\ude00"}', 0xDC00), +] + +# The boundary the #1308 refusal must not overshoot, and the documents +# neither refusal may touch. +_JSON_ACCEPTED_CASES = [ + ("paired_surrogate_value", '{"k":"a\\ud83d\\ude00b"}', '{"k":"a\U0001F600b"}'), + ("paired_surrogate_upper", '{"k":"a\\uD83D\\uDE00b"}', '{"k":"a\U0001F600b"}'), + ("paired_surrogate_key", '{"a\\ud83d\\ude00b":1}', '{"a\U0001F600b":1}'), + ("two_pairs", '["\\ud83d\\ude00\\ud83d\\ude80"]', '["\U0001F600\U0001F680"]'), + ("pair_at_end", '{"k":"ab\\ud83d\\ude00"}', '{"k":"ab\U0001F600"}'), + ("literal_astral", '{"k":"\U0001F600"}', '{"k":"\U0001F600"}'), + ("nan_as_string_value", '{"k":"NaN"}', '{"k":"NaN"}'), + ("infinity_as_string_value", '{"k":"Infinity"}', '{"k":"Infinity"}'), + ("nan_as_key", '{"NaN":1}', '{"NaN":1}'), + ("negative_number", "-1.5", "-1.5"), + ("object_and_array", '{"a":1,"b":[true,null]}', '{"a":1,"b":[true,null]}'), + ("escaped_backslash_u", '{"k":"\\\\ud800"}', '{"k":"\\\\ud800"}'), +] + + +# A syntactically valid number that overflows Float64 — the second entry +# route to a non-finite JNumber, and the one the constant refusal alone +# left open on BOTH hosts. +_JSON_OVERFLOW_CASES = [ + ("bare", "1e999", "Infinity"), + ("bare_negative", "-1e999", "-Infinity"), + ("in_array", "[1e999]", "Infinity"), + ("in_object", '{"a":1e309}', "Infinity"), + ("capital_exponent", "1E999", "Infinity"), + ("doubly_nested", "[[1e999]]", "Infinity"), + ("negative_in_object", '{"a":-1e999}', "-Infinity"), +] + +# Finite boundary controls, underflow among them: 1e-999 decodes to 0, +# which is finite and in the domain. +_JSON_FINITE_BOUNDARY_CASES = [ + ("max_float", "1e308", "1e+308"), + ("negative_max_float", "-1e308", "-1e+308"), + ("largest_representable", "1.7976931348623157e308", + "1.7976931348623157e+308"), + ("underflow_to_zero", "1e-999", "0"), + ("underflow_in_array", "[1e-999]", "[0]"), +] + +# Text that is malformed for a reason the domain has nothing to say +# about. Each must keep its host-native syntax message on both hosts — +# these are where a scan that matched a constant token anywhere, rather +# than only where a value may begin, would manufacture a shared sentence +# on one host and not the other. +_JSON_HOST_NATIVE_ERROR_CASES = [ + ("malformed", "{not json"), + ("constant_lookalike", "[Infinity_x]"), + ("nan_lookalike", "[NaNx]"), + ("constant_as_bare_key", "{Infinity:1}"), + ("signed_nan", "-NaN"), + ("signed_nan_in_array", "[-NaN]"), + ("plus_infinity", "+Infinity"), + ("lowercase_infinity", "infinity"), + ("lowercase_nan", "nan"), + ("constant_suffix", "-Infinityx"), +] + + +# The integer arm of the overflow route. ``json.loads`` yields a Python +# ``int`` for a digit string with no fraction or exponent, so these never +# reach a float range check on the reference host; ``JSON.parse`` has no +# such split and produced an ``Infinity`` here all along. The bound is +# the double ROUNDING boundary — an integer above ``sys.float_info.max`` +# but below the midpoint to 2**1024 rounds down and is accepted by both. +_INT_ROUNDS_TO_INFINITY = 2**1024 - 2**970 +_MAX_FINITE_AS_INT = int(sys.float_info.max) + +_JSON_INT_OVERFLOW_CASES = [ + ("digits_309", "1" + "0" * 309, "Infinity"), + ("digits_400", "1" + "0" * 400, "Infinity"), + ("negative_309", "-1" + "0" * 309, "-Infinity"), + ("in_array", "[1" + "0" * 309 + "]", "Infinity"), + ("in_object", '{"a":1' + "0" * 309 + "}", "Infinity"), + ("exact_rounding_boundary", str(_INT_ROUNDS_TO_INFINITY), "Infinity"), +] + +_JSON_INT_ACCEPTED_CASES = [ + ("digits_308", "1" + "0" * 308, "1e+308"), + ("negative_digits_308", "-1" + "0" * 308, "-1e+308"), + ("boundary_minus_one", str(_INT_ROUNDS_TO_INFINITY - 1), + "1.7976931348623157e+308"), + ("max_finite_as_int_plus_one", str(_MAX_FINITE_AS_INT + 1), + "1.7976931348623157e+308"), + ("ordinary_integer", "42", "42"), +] + + +class TestBrowserJsonAcceptDomainParity1306_1308: + """``json_parse`` accepts the same texts on both hosts (#1306, #1308). + + Three exclusions, and only one of them was a disagreement BETWEEN + the hosts. For the JavaScript constants the reference host was the + lax one — Python's ``json.loads`` admits ``NaN`` / ``Infinity`` / + ``-Infinity`` through its default ``parse_constant``, so the text + parsed and the refusal landed at ``json_stringify`` instead, a + *different call* from the browser's (#1306). + + The other two diverged from the stated domain on BOTH hosts at once, + which is the harder shape to notice because a parity suite sees + nothing wrong. A lone-surrogate escape was accepted by both parsers + and the memory boundary decided what happened next — ``TextEncoder`` + substituted U+FFFD in the browser, ``.encode()`` raised in the + reference host (#1308). A number that overflows (``1e999``) is + accepted by both parsers as well, decoding to an infinite + ``JNumber`` on each, and then dying at ``json_stringify`` on each + (#1306 again, by a second entry route). + + Vera's own domain now settles all three, at one refusal point: + ``json_parse`` accepts exactly RFC 8259-valid text that decodes to + finite numbers and strings of Unicode scalar values. + + Every case runs the SAME ``.wasm`` under both runtimes and compares + the full stdout, so the assertion covers the arm taken *and* the + message — and the expected message is imported from the reference + implementation, holding ``runtime.mjs``'s hand-copied duplicate + against the original rather than against a fragment loose enough for + both to satisfy while saying different things. + """ + + @pytest.mark.parametrize( + ("case_id", "raw_json", "name"), + _JSON_NON_FINITE_CASES, + ids=[c[0] for c in _JSON_NON_FINITE_CASES], + ) + def test_non_finite_constants_refused_identically( + self, case_id: str, raw_json: str, name: str, tmp_path: Path, + ) -> None: + out = _parity_stdout( + _accept_domain_src(raw_json), tmp_path, f"jsonnfp_{case_id}", + ) + assert out == "ERR:" + _non_finite_parse_message(name) + + @pytest.mark.parametrize( + ("case_id", "raw_json", "code_point"), + _JSON_LONE_SURROGATE_CASES, + ids=[c[0] for c in _JSON_LONE_SURROGATE_CASES], + ) + def test_lone_surrogates_refused_identically( + self, case_id: str, raw_json: str, code_point: int, tmp_path: Path, + ) -> None: + out = _parity_stdout( + _accept_domain_src(raw_json), tmp_path, f"jsonls_{case_id}", + ) + assert out == "ERR:" + _lone_surrogate_message(code_point) + + @pytest.mark.parametrize( + ("case_id", "raw_json", "expected"), + _JSON_ACCEPTED_CASES, + ids=[c[0] for c in _JSON_ACCEPTED_CASES], + ) + def test_accepted_documents_are_unchanged( + self, case_id: str, raw_json: str, expected: str, tmp_path: Path, + ) -> None: + """Controls, run beside the refusals rather than in another file. + + A paired surrogate escape is the ordinary way to write an astral + character, and ``"NaN"`` as a string value is ordinary JSON — a + refusal that reached either of them would break real documents, + and would still look like a pass to a battery that only asserted + the refusals fire. + """ + out = _parity_stdout( + _accept_domain_src(raw_json), tmp_path, f"jsonok_{case_id}", + ) + assert out == "OK:" + expected + + @pytest.mark.parametrize( + ("case_id", "raw_json", "name"), + _JSON_OVERFLOW_CASES, + ids=[c[0] for c in _JSON_OVERFLOW_CASES], + ) + def test_overflow_to_infinity_refused_identically( + self, case_id: str, raw_json: str, name: str, tmp_path: Path, + ) -> None: + """The route the constant refusal left open, on both hosts. + + ``1e999`` is grammatically valid RFC 8259 that ``json.loads`` + and ``JSON.parse`` both accept, decoding to an infinite number + on each — so before this the domain's "no non-finite value gets + in" claim was false in the same way on both hosts, and the + program died at ``json_stringify`` instead. + """ + out = _parity_stdout( + _accept_domain_src(raw_json), tmp_path, f"jsonovf_{case_id}", + ) + assert out == "ERR:" + _non_finite_number_message(name) + + @pytest.mark.parametrize( + ("case_id", "raw_json", "expected"), + _JSON_FINITE_BOUNDARY_CASES, + ids=[c[0] for c in _JSON_FINITE_BOUNDARY_CASES], + ) + def test_finite_numbers_at_the_boundary_are_unchanged( + self, case_id: str, raw_json: str, expected: str, tmp_path: Path, + ) -> None: + """Including underflow, which is a different question. + + ``1e-999`` names a value neither host can represent either, but + what it decodes to is ``0`` — finite, and in the domain. A + refusal generalised from "the text names an unrepresentable + magnitude" rather than from "the decoded number is not finite" + would take it. + """ + out = _parity_stdout( + _accept_domain_src(raw_json), tmp_path, f"jsonfin_{case_id}", + ) + assert out == "OK:" + expected + + @pytest.mark.parametrize( + ("case_id", "raw_json", "name"), + _JSON_INT_OVERFLOW_CASES, + ids=[c[0] for c in _JSON_INT_OVERFLOW_CASES], + ) + def test_integer_overflow_refused_identically( + self, case_id: str, raw_json: str, name: str, tmp_path: Path, + ) -> None: + """The route only the reference host had a hole in. + + A digit string with no fraction and no exponent decodes to a + Python ``int``, which a float-only range check never examined — + and then had to become an f64 at the WASM boundary, where the + conversion raised. ``JSON.parse`` produces a double either way, + so the browser side of this parity assertion was already right; + what it pins is that the reference host now says the same + sentence rather than dying with a CPython one. + """ + out = _parity_stdout( + _accept_domain_src(raw_json), tmp_path, f"jsonint_{case_id}", + ) + assert out == "ERR:" + _non_finite_number_message(name) + + @pytest.mark.parametrize( + ("case_id", "raw_json", "expected"), + _JSON_INT_ACCEPTED_CASES, + ids=[c[0] for c in _JSON_INT_ACCEPTED_CASES], + ) + def test_integers_that_round_into_range_are_unchanged( + self, case_id: str, raw_json: str, expected: str, tmp_path: Path, + ) -> None: + """The boundary pair, and the band between the two candidate bounds. + + ``max_finite_as_int_plus_one`` is larger than the largest finite + double and still rounds to it, so both hosts accept it. A + reference-host bound of ``sys.float_info.max`` would refuse it + and trade one divergence for its mirror image — invisible to any + battery whose only large case is a round number of zeros. + """ + out = _parity_stdout( + _accept_domain_src(raw_json), tmp_path, f"jsonintok_{case_id}", + ) + assert out == "OK:" + expected + + @pytest.mark.parametrize( + ("case_id", "raw_json"), + _JSON_HOST_NATIVE_ERROR_CASES, + ids=[c[0] for c in _JSON_HOST_NATIVE_ERROR_CASES], + ) + def test_malformed_text_keeps_its_host_native_message( + self, case_id: str, raw_json: str, tmp_path: Path, + ) -> None: + """Only the domain refusals are shared sentences. + + Syntax errors keep their host-native message — Python ``json`` + on one side, ECMAScript ``JSON`` on the other — the long-standing + convention ``TestBrowserHostErrorPaths349`` documents. The + browser reaches its shared sentence by asking whether stripping + the bare constants makes the text parse, and it only considers a + token where a value may begin; ``-NaN`` is the case that needs + both rules, since the substitution alone would turn it into + ``-0`` and report a refusal the reference host never makes. + """ + native, browser = _both_stdouts( + _accept_domain_src(raw_json), tmp_path, f"jsonsyn_{case_id}", + ) + assert native.startswith("ERR:") + assert browser.startswith("ERR:") + assert "json_parse:" not in native, native + assert "json_parse:" not in browser, browser + + def test_a_non_finite_constant_outranks_a_lone_surrogate( + self, tmp_path: Path, + ) -> None: + """Precedence, pinned, because the two hosts reach it differently. + + The reference host never gets to the surrogate scan — the + constant makes ``json.loads`` itself raise. The browser never + gets to ``parseJsonOrdered`` — ``JSON.parse`` refused the text. + Both arrive at the non-finite sentence, but only a test says so. + """ + out = _parity_stdout( + _accept_domain_src('["\\ud800",NaN]'), tmp_path, "jsonprec", + ) + assert out == "ERR:" + _non_finite_parse_message("NaN") + + def test_the_two_walk_refusals_share_one_document_order( + self, tmp_path: Path, + ) -> None: + """Overflow and lone surrogate are found by ONE walk, both hosts. + + Both are properties of the decoded value, so both are found by + the same document-order traversal and whichever comes first + names the refusal. Two hosts each with its own precedence rule + would agree on every single-violation document and diverge only + here. + """ + assert _parity_stdout( + _accept_domain_src('["a\\ud800b",1e999]'), tmp_path, "jsonwalk1", + ) == "ERR:" + _lone_surrogate_message(0xD800) + assert _parity_stdout( + _accept_domain_src('[1e999,"a\\ud800b"]'), tmp_path, "jsonwalk2", + ) == "ERR:" + _non_finite_number_message("Infinity") + + class TestCanonicalNumberFormatMatchesEcmascript1293: """``format_json_number`` is differentially checked against the real ``JSON.stringify``, not against a table someone typed (#1293). diff --git a/tests/test_json_accept_domain_1306_1308.py b/tests/test_json_accept_domain_1306_1308.py new file mode 100644 index 00000000..c9176e89 --- /dev/null +++ b/tests/test_json_accept_domain_1306_1308.py @@ -0,0 +1,742 @@ +"""``json_parse``'s accept domain (#1306, #1308). + +Vera defines its own domain for ``json_parse`` rather than inheriting +whichever one the host parser happens to implement: + + ``json_parse`` accepts exactly RFC 8259-valid text that decodes to + finite numbers and strings of Unicode scalar values; everything + else is a handled ``Err``, identically on both hosts, at the parse. + +Two texts sit outside that domain and used to be admitted by accident on +the reference host, each by a different mechanism: + +* **#1306** — a non-finite number, by either of two routes. The + JavaScript constants ``NaN`` / ``Infinity`` / ``-Infinity`` are + admitted by Python's default ``parse_constant`` and refused by + ``JSON.parse``, so the reference host accepted the text and the + refusal landed at ``json_stringify`` instead — a *different call* on + each host. A syntactically valid number that OVERFLOWS (``1e999``) is + accepted by both parsers, so that route diverged from the stated + domain on both hosts at once, which is the harder failure to notice: + nothing disagreed. + +* **#1308** — a lone-surrogate escape (``\\ud800`` with no paired low + surrogate). The text is grammatically valid RFC 8259, but its decoded + value is not a Unicode scalar sequence and so has no UTF-8 encoding. + The reference host used to die with a raw ``UnicodeEncodeError`` from + ``_alloc_string``; the browser's ``TextEncoder`` silently substituted + U+FFFD. + +Both refusals now happen at the parse, with one sentence per refusal +shared verbatim between ``vera/runtime/json.py`` and +``vera/browser/runtime.mjs``. The cross-host half of this battery lives +in ``tests/test_browser.py`` +(``TestBrowserJsonAcceptDomainParity1306_1308``); this file pins the +reference host and the shared sentences themselves. +""" + +from __future__ import annotations + +import sys + +import pytest + +from tests.codegen_helpers import _run_io +from vera.wasm.json_serde import ( + _lone_surrogate_message, + _non_finite_number_message, + _non_finite_parse_message, + first_domain_violation, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _vera_lit(raw: str) -> str: + """Escape ``raw`` for embedding in a Vera string literal. + + The battery's inputs are JSON documents full of quotes and + backslashes, and one backslash either way changes which bytes + ``json_parse`` receives — a surrogate escape and a literal + backslash-u sequence differ by exactly that. Hand-escaping at + fifteen call sites is where a test silently stops testing what it + claims; converting once at the boundary keeps every case honest. + """ + return raw.replace("\\", "\\\\").replace('"', '\\"') + + +_PROBE = """ +public fn main(@Unit -> @Unit) + requires(true) ensures(true) effects() +{{ + match json_parse("{text}") {{ + Ok(@Json) -> IO.print(string_concat("OK:", json_stringify(@Json.0))), + Err(@String) -> IO.print(string_concat("ERR:", @String.0)) + }} +}} +""" + + +def _parse_probe(raw_json: str) -> str: + """Run ``json_parse(raw_json)`` and report the arm taken. + + Returns ``"OK:"`` or ``"ERR:"`` — the same + observation the browser battery makes, so the two files compare + like with like. + """ + return _run_io(_PROBE.format(text=_vera_lit(raw_json))) + + +# The four probe inputs from #1306's table, plus the two-constant case +# that pins WHICH constant names the refusal. +_NON_FINITE_CASES = [ + ("bare_nan", "NaN", "NaN"), + ("bare_infinity", "Infinity", "Infinity"), + ("bare_negative_infinity", "-Infinity", "-Infinity"), + # ``json.loads`` admits the constants inside containers too, so the + # refusal has to reach there and not just the top-level value. + ("nan_in_array", "[NaN]", "NaN"), + ("infinity_in_object", '{"a":Infinity}', "Infinity"), + ("negative_infinity_in_object", '{"a":-Infinity}', "-Infinity"), + # First in document order names the refusal — the order + # ``parse_constant`` is called in, and the order the browser's + # twin scan finds them in. + ("first_of_two_wins", "[NaN,Infinity]", "NaN"), +] + +# Positions × escape casings for #1308. The check has to cover keys as +# well as values, at any nesting depth, and must not care how the user +# spelled the hex digits. +_LONE_SURROGATE_CASES = [ + ("value_lower", '{"k":"a\\ud800b"}', 0xD800), + ("value_upper", '{"k":"a\\uD800b"}', 0xD800), + ("value_low_surrogate", '{"k":"a\\udc00b"}', 0xDC00), + ("value_low_surrogate_upper", '{"k":"a\\uDC00b"}', 0xDC00), + ("key", '{"a\\ud800b":1}', 0xD800), + ("key_upper", '{"a\\uD800b":1}', 0xD800), + ("array_element", '["a\\ud800b"]', 0xD800), + ("nested_object", '{"o":{"k":"a\\ud800b"}}', 0xD800), + ("nested_array_in_object", '{"o":[1,"a\\ud800b"]}', 0xD800), + ("top_level_string", '"a\\ud800b"', 0xD800), + # High surrogate followed by something that is NOT a low surrogate: + # the pair-aware scan must not treat the next unit as a partner. + ("high_then_ascii_escape", '{"k":"\\ud800\\u0041"}', 0xD800), + # High surrogate followed by another high surrogate. + ("high_then_high", '{"k":"\\ud800\\ud800"}', 0xD800), + # Low surrogate FIRST, then a well-formed pair — the leading unit is + # lone even though a valid pair follows it. + ("low_then_valid_pair", '{"k":"\\udc00\\ud83d\\ude00"}', 0xDC00), +] + +# Controls: paired surrogates encode real astral characters and MUST +# still parse. This is the boundary the #1308 check must not overshoot. +_PAIRED_SURROGATE_CASES = [ + ("paired_value", '{"k":"a\\ud83d\\ude00b"}', '{"k":"a\U0001F600b"}'), + ("paired_value_upper", '{"k":"a\\uD83D\\uDE00b"}', '{"k":"a\U0001F600b"}'), + ("paired_key", '{"a\\ud83d\\ude00b":1}', '{"a\U0001F600b":1}'), + ("paired_array_element", '["\\ud83d\\ude00"]', '["\U0001F600"]'), + # Two pairs back to back: the scan must consume each pair whole and + # not read the low of the first beside the high of the second. + ("two_pairs", '["\\ud83d\\ude00\\ud83d\\ude80"]', '["\U0001F600\U0001F680"]'), + # A pair at the very end of the string — the "is there a next unit?" + # bound is where an off-by-one turns a valid pair into a lone high. + ("pair_at_end", '{"k":"ab\\ud83d\\ude00"}', '{"k":"ab\U0001F600"}'), + # The literal (non-escaped) astral character, for good measure. + ("literal_astral", '{"k":"\U0001F600"}', '{"k":"\U0001F600"}'), +] + +# Controls: valid documents whose behaviour must be untouched by either +# refusal. ``"NaN"`` as a *string value* is ordinary JSON. +_VALID_CASES = [ + ("null", "null", "null"), + ("number", "1.5", "1.5"), + ("negative_number", "-1.5", "-1.5"), + ("nan_as_string_value", '{"k":"NaN"}', '{"k":"NaN"}'), + ("infinity_as_string_value", '{"k":"Infinity"}', '{"k":"Infinity"}'), + ("nan_as_key", '{"NaN":1}', '{"NaN":1}'), + ("object_and_array", '{"a":1,"b":[true,null]}', '{"a":1,"b":[true,null]}'), + ("escaped_backslash_u", '{"k":"\\\\ud800"}', '{"k":"\\\\ud800"}'), +] + + +# --------------------------------------------------------------------------- +# #1306 — the JavaScript non-finite constants +# --------------------------------------------------------------------------- + + +class TestNonFiniteParseRefusal1306: + """``NaN`` / ``Infinity`` / ``-Infinity`` are refused at the parse. + + Before the fix the reference host parsed all of these into a + ``JNumber`` and the program only failed later, at ``json_stringify`` + — and then as a raw Python traceback (#1302), not an ``Err``. The + browser refused at the parse all along, so the two hosts disagreed + about *which call* rejects a non-finite value. + """ + + @pytest.mark.parametrize( + ("case_id", "raw_json", "name"), + _NON_FINITE_CASES, + ids=[c[0] for c in _NON_FINITE_CASES], + ) + def test_refused_with_the_shared_sentence( + self, case_id: str, raw_json: str, name: str, + ) -> None: + assert _parse_probe(raw_json) == "ERR:" + _non_finite_parse_message(name) + + def test_message_names_the_constant_and_the_remedy(self) -> None: + """Guards the guard: the sentence has to carry both halves. + + A message that named the constant but not what to do about it + would satisfy an equality assertion against itself while telling + the user nothing — the assertions above compare the production + sentence with itself, so the *content* needs its own check. + """ + msg = _non_finite_parse_message("NaN") + assert "NaN" in msg + assert "RFC 8259" in msg + assert "json_parse:" in msg + # The remedy half, per the diagnostic house style. + assert "quote" in msg or "null" in msg + + @pytest.mark.parametrize( + ("case_id", "raw_json", "expected"), + _VALID_CASES, + ids=[c[0] for c in _VALID_CASES], + ) + def test_valid_documents_are_unaffected( + self, case_id: str, raw_json: str, expected: str, + ) -> None: + assert _parse_probe(raw_json) == "OK:" + expected + + def test_malformed_text_keeps_the_host_parser_message(self) -> None: + """The refinement must not swallow ordinary syntax errors. + + Only the non-finite constants get the shared sentence; text that + is malformed for any other reason still reports whatever the + host parser said, which is the pre-existing (and deliberately + host-native) behaviour for syntax errors. + """ + out = _parse_probe("{not json") + assert out.startswith("ERR:") + assert _non_finite_parse_message("NaN") not in out + assert "json_parse:" not in out + + @pytest.mark.parametrize( + ("case_id", "raw_json"), + [ + # Python's scanner calls ``parse_constant`` the moment it + # sees the token, so a hook that RAISED would report the + # non-finite sentence for this — while the browser, which + # decides by substituting and re-parsing, reported a syntax + # error. The recording hook asks the browser's question. + ("constant_prefix", "[Infinity_x]"), + ("nan_prefix", "[NaNx]"), + # A constant in a key position: neither parser gets far + # enough to consider it a value. + ("constant_as_bare_key", "{Infinity:1}"), + # A sign the constant cannot take. RFC 8259 gives `-` to + # numbers, and neither host's parser reads `-NaN` as + # anything at all; a scan matching the token wherever it + # appeared would find `NaN` at offset 1 and claim the + # domain had refused it. + ("signed_nan", "-NaN"), + ("signed_nan_in_array", "[-NaN]"), + # The mirror-image sign error, which no token begins with. + ("plus_infinity", "+Infinity"), + # Case matters: the constants are spelled exactly one way. + ("lowercase_infinity", "infinity"), + ("lowercase_nan", "nan"), + ("constant_suffix", "-Infinityx"), + ], + ids=["constant_prefix", "nan_prefix", "constant_as_bare_key", + "signed_nan", "signed_nan_in_array", "plus_infinity", + "lowercase_infinity", "lowercase_nan", "constant_suffix"], + ) + def test_a_constant_lookalike_is_not_reported_as_non_finite( + self, case_id: str, raw_json: str, + ) -> None: + """The shared sentence is for text whose ONLY defect is a constant. + + Text that is malformed for an additional reason keeps the host + parser's own syntax message — the long-standing convention for + syntax errors, and the only rule both hosts can implement + identically. + """ + out = _parse_probe(raw_json) + assert out.startswith("ERR:") + assert "json_parse:" not in out + + def test_a_non_finite_constant_outranks_a_lone_surrogate(self) -> None: + """Precedence between the two refusals, pinned. + + ``json.loads`` reaches the end of the text before the + surrogate scan runs at all, so the constant is recorded and + wins. The browser arrives at the same answer by a different + route (``JSON.parse`` rejects the text outright, so its + surrogate scan never runs either) — see the parity twin in + ``tests/test_browser.py``. + """ + assert _parse_probe('["\\ud800",NaN]') == ( + "ERR:" + _non_finite_parse_message("NaN") + ) + + +# A syntactically valid number whose magnitude overflows Float64. RFC +# 8259 §6 sets no range limit but explicitly permits an implementation to +# set one, so this is the second entry route to a non-finite JNumber and +# the domain has to close it or the "no entry route" claim is false. +_OVERFLOW_CASES = [ + ("bare", "1e999", "Infinity"), + ("bare_negative", "-1e999", "-Infinity"), + ("in_array", "[1e999]", "Infinity"), + ("in_object", '{"a":1e309}', "Infinity"), + ("capital_exponent", "1E999", "Infinity"), + ("doubly_nested", "[[1e999]]", "Infinity"), + ("negative_in_object", '{"a":-1e999}', "-Infinity"), +] + +# Finite boundary controls the overflow refusal must not reach. The +# underflow case is the one that needs a decision rather than a check: +# 1e-999 decodes to 0.0, which is finite and therefore in the domain. +_FINITE_BOUNDARY_CASES = [ + ("max_float", "1e308", "1e+308"), + ("negative_max_float", "-1e308", "-1e+308"), + ("largest_representable", "1.7976931348623157e308", "1.7976931348623157e+308"), + ("underflow_to_zero", "1e-999", "0"), + ("negative_underflow_to_zero", "-1e-999", "0"), + ("underflow_in_array", "[1e-999]", "[0]"), +] + + +class TestNonFiniteNumberOverflowRefusal1306: + """A number that overflows to an infinity is refused at the parse. + + The constant refusal alone left the domain open: ``1e999`` is + grammatically valid RFC 8259 that both host parsers accept, decoding + to an infinite ``JNumber`` that then died at ``json_stringify`` — + the very route #1306 claims to have closed, reached by a different + syntax. RFC 8259 §6 sets no limit on a number's range but says in + so many words that an implementation may set one; §9.7.1 sets Vera's + at the finite ``Float64`` values, which is also exactly what + ``json_stringify`` can write back. + + Underflow is not the same question and is not refused: ``1e-999`` + decodes to ``0``, which is finite and in the domain. + """ + + @pytest.mark.parametrize( + ("case_id", "raw_json", "name"), + _OVERFLOW_CASES, + ids=[c[0] for c in _OVERFLOW_CASES], + ) + def test_overflow_is_refused_with_the_shared_sentence( + self, case_id: str, raw_json: str, name: str, + ) -> None: + assert _parse_probe(raw_json) == "ERR:" + _non_finite_number_message( + name, + ) + + @pytest.mark.parametrize( + ("case_id", "raw_json", "expected"), + _FINITE_BOUNDARY_CASES, + ids=[c[0] for c in _FINITE_BOUNDARY_CASES], + ) + def test_finite_numbers_at_the_boundary_still_parse( + self, case_id: str, raw_json: str, expected: str, + ) -> None: + """The controls that make the refusal a boundary and not a wall. + + ``1.7976931348623157e308`` is the largest finite double: a + refusal keyed on "large" rather than on "not finite" would take + it, and take every legitimate scientific document with it. + """ + assert _parse_probe(raw_json) == "OK:" + expected + + def test_underflow_decodes_to_zero_rather_than_being_refused( + self, + ) -> None: + """The decision, stated as a test as well as in the spec. + + ``1e-999`` is as unrepresentable as ``1e999`` in the sense that + the value the text names is not the value you get — but what you + get is ``0``, a finite number the format and the language both + carry, so the domain admits it. Pinned explicitly because + "symmetry with overflow" is the plausible wrong answer. + """ + assert _parse_probe("1e-999") == "OK:0" + assert _parse_probe("[1e-999,1e999]") == ( + "ERR:" + _non_finite_number_message("Infinity") + ) + + def test_a_constant_outranks_an_overflow(self) -> None: + """Both hosts reach the constant sentence, by different routes. + + The reference host records the constant during the parse and + checks it before walking the decoded tree; the browser never + parses the text at all. Pinned in both orders so the answer + cannot depend on which appears first. + """ + expected = "ERR:" + _non_finite_parse_message("NaN") + assert _parse_probe("[NaN,1e999]") == expected + assert _parse_probe("[1e999,NaN]") == expected + + def test_document_order_decides_between_the_two_walk_refusals( + self, + ) -> None: + """Overflow and lone surrogate are found by ONE walk. + + Both are properties of the decoded value rather than of the + text, so both are found by the same document-order traversal and + whichever comes first names the refusal — a rule that needs no + precedence table and that the two hosts cannot implement + differently. + """ + assert _parse_probe('["a\\ud800b",1e999]') == ( + "ERR:" + _lone_surrogate_message(0xD800) + ) + assert _parse_probe('[1e999,"a\\ud800b"]') == ( + "ERR:" + _non_finite_number_message("Infinity") + ) + + +# The integer arm of the overflow route. ``json.loads`` yields a Python +# ``int`` — not a float — for a digit string with no fraction and no +# exponent, so ``1`` followed by 309 zeros never reaches a float range +# check at all. It reaches ``write_json``'s ``float(value)`` instead, +# where the conversion raises. JS has no such split: ``JSON.parse`` +# produces a double either way, so the browser was right about these all +# along and only the reference host had a hole. +# +# The boundary is the double rounding boundary, not ``sys.float_info.max``. +# An integer strictly between the largest finite double and the midpoint +# to 2**1024 rounds DOWN to that double and is perfectly representable — +# ``int(sys.float_info.max) + 1`` is such an integer, and both hosts +# accept it. A comparison against ``int(sys.float_info.max)`` would +# refuse it on the reference host alone, trading this divergence for its +# mirror image. +_INT_ROUNDS_TO_INFINITY = 2**1024 - 2**970 +_MAX_FINITE_AS_INT = int(sys.float_info.max) + +_INT_OVERFLOW_CASES = [ + ("digits_309", "1" + "0" * 309, "Infinity"), + ("digits_310", "1" + "0" * 310, "Infinity"), + # 400 digits: far past anything a float conversion could survive, so + # a comparison implemented AS a float conversion raises here instead + # of refusing. The assertion is on the Err, never on an exception. + ("digits_400", "1" + "0" * 400, "Infinity"), + ("negative_309", "-1" + "0" * 309, "-Infinity"), + ("in_array", "[1" + "0" * 309 + "]", "Infinity"), + ("in_object", '{"a":1' + "0" * 309 + "}", "Infinity"), + ("nested", "[[1" + "0" * 309 + "]]", "Infinity"), + ("exact_rounding_boundary", str(_INT_ROUNDS_TO_INFINITY), "Infinity"), + ("negative_exact_boundary", "-" + str(_INT_ROUNDS_TO_INFINITY), + "-Infinity"), +] + +_INT_ACCEPTED_CASES = [ + ("digits_308", "1" + "0" * 308, "1e+308"), + ("negative_digits_308", "-1" + "0" * 308, "-1e+308"), + ("boundary_minus_one", str(_INT_ROUNDS_TO_INFINITY - 1), + "1.7976931348623157e+308"), + ("max_finite_as_int", str(_MAX_FINITE_AS_INT), + "1.7976931348623157e+308"), + # The control that separates the rounding boundary from + # ``sys.float_info.max``: this integer is LARGER than the largest + # finite double and still rounds to it. + ("max_finite_as_int_plus_one", str(_MAX_FINITE_AS_INT + 1), + "1.7976931348623157e+308"), + ("ordinary_integer", "42", "42"), + ("negative_ordinary_integer", "-42", "-42"), +] + + +class TestIntegerOverflowRefusal1306: + """An integer literal too large for a double is refused at the parse. + + The float arm of the walk cannot see these. ``json.loads`` returns + an ``int`` for a digit string with no fraction and no exponent, and + a Python ``int`` is never infinite however many digits it has — the + reasoning that made a float-only range check look complete. What it + missed is that the value still has to BECOME a double at the WASM + boundary: ``write_json`` calls ``float(value)``, which raises + ``OverflowError`` for a magnitude past the rounding boundary. So a + text the browser refused with the shared sentence killed the + reference host with a CPython message instead — an `Err`-at-parse + MUST and a same-message-on-every-runtime MUST, both broken by one + shape. + + The comparison is integer arithmetic against an exact integer bound. + Implementing it as ``float(value)`` would be the very overflow it is + meant to detect. + """ + + @pytest.mark.parametrize( + ("case_id", "raw_json", "name"), + _INT_OVERFLOW_CASES, + ids=[c[0] for c in _INT_OVERFLOW_CASES], + ) + def test_integer_overflow_is_refused_with_the_shared_sentence( + self, case_id: str, raw_json: str, name: str, + ) -> None: + assert _parse_probe(raw_json) == "ERR:" + _non_finite_number_message( + name, + ) + + @pytest.mark.parametrize( + ("case_id", "raw_json", "expected"), + _INT_ACCEPTED_CASES, + ids=[c[0] for c in _INT_ACCEPTED_CASES], + ) + def test_integers_that_round_into_range_still_parse( + self, case_id: str, raw_json: str, expected: str, + ) -> None: + assert _parse_probe(raw_json) == "OK:" + expected + + def test_the_bound_is_the_rounding_boundary_not_the_largest_double( + self, + ) -> None: + """The two candidate bounds differ, and only one matches the browser. + + Everything in ``[int(sys.float_info.max), boundary)`` rounds down + to the largest finite double and is accepted by ``JSON.parse``. + A reference-host check against ``sys.float_info.max`` would + refuse that band and diverge again — the same defect with its + sign flipped, and invisible to a battery whose only large case + is a round number of zeros. + """ + assert _MAX_FINITE_AS_INT < _INT_ROUNDS_TO_INFINITY + assert first_domain_violation(_MAX_FINITE_AS_INT) is None + assert first_domain_violation(_MAX_FINITE_AS_INT + 1) is None + assert first_domain_violation(_INT_ROUNDS_TO_INFINITY - 1) is None + assert first_domain_violation(_INT_ROUNDS_TO_INFINITY) == ( + _non_finite_number_message("Infinity") + ) + assert first_domain_violation(-_INT_ROUNDS_TO_INFINITY) == ( + _non_finite_number_message("-Infinity") + ) + + def test_the_bound_agrees_with_python_s_own_float_conversion( + self, + ) -> None: + """A differential, because the bound is a hand-derived constant. + + ``2**1024 - 2**970`` is the midpoint between the largest finite + double and ``2**1024``, and ties-to-even sends it upward — but + that is a derivation, and a derivation is what a test is for. + The oracle is ``float()`` itself: below the bound it succeeds, + at and above it raises. + """ + assert float(_INT_ROUNDS_TO_INFINITY - 1) == sys.float_info.max + with pytest.raises(OverflowError): + float(_INT_ROUNDS_TO_INFINITY) + + def test_a_huge_integer_is_refused_rather_than_raising(self) -> None: + """The 400-digit case, stated as its own property. + + A range check written as a float conversion does not merely give + the wrong answer here — it raises, which a parametrized equality + assertion would report as an error rather than as the divergence + it is. Asserting the ``Err`` value directly says what must + happen. + """ + assert _parse_probe("1" + "0" * 400) == ( + "ERR:" + _non_finite_number_message("Infinity") + ) + + def test_a_bool_is_still_not_a_number(self) -> None: + """``bool`` subclasses ``int``; the int arm must not claim it.""" + assert first_domain_violation(True) is None + assert first_domain_violation([True, False]) is None + assert _parse_probe("[true,false]") == "OK:[true,false]" + + +# --------------------------------------------------------------------------- +# #1308 — lone-surrogate escapes +# --------------------------------------------------------------------------- + + +class TestLoneSurrogateParseRefusal1308: + """A lone-surrogate escape is refused at the parse, keys included. + + RFC 8259 permits the *text*; Unicode does not permit the *value*. + A lone surrogate is not a scalar value and has no UTF-8 encoding, so + it cannot cross the WASM boundary into a Vera string at all — which + is why the pre-fix reference host died inside ``_alloc_string`` + rather than returning anything. + """ + + @pytest.mark.parametrize( + ("case_id", "raw_json", "code_point"), + _LONE_SURROGATE_CASES, + ids=[c[0] for c in _LONE_SURROGATE_CASES], + ) + def test_refused_with_the_shared_sentence( + self, case_id: str, raw_json: str, code_point: int, + ) -> None: + assert _parse_probe(raw_json) == "ERR:" + _lone_surrogate_message( + code_point, + ) + + @pytest.mark.parametrize( + ("case_id", "raw_json", "expected"), + _PAIRED_SURROGATE_CASES, + ids=[c[0] for c in _PAIRED_SURROGATE_CASES], + ) + def test_paired_surrogates_still_parse( + self, case_id: str, raw_json: str, expected: str, + ) -> None: + """The boundary the refusal must not overshoot. + + ``\\ud83d\\ude00`` is the ordinary way to write U+1F600 in JSON. + A check that refused any code unit in the surrogate range would + break every astral character in every document — so these run + beside the refusals rather than in a separate file. + """ + assert _parse_probe(raw_json) == "OK:" + expected + + def test_message_names_the_code_point_and_the_remedy(self) -> None: + """Guards the guard — same reasoning as the #1306 twin.""" + msg = _lone_surrogate_message(0xD800) + assert "\\uD800" in msg + assert "surrogate" in msg + assert "json_parse:" in msg + assert "pair" in msg + + def test_message_normalises_the_hex_casing(self) -> None: + """One sentence per code point, whatever the input looked like. + + The escape can be written ``\\ud800`` or ``\\uD800``; the + refusal names the code point, so both spellings produce the same + message and the parity battery can compare across hosts without + a casing rule. + """ + assert _lone_surrogate_message(0xD800) == _lone_surrogate_message(0xD800) + assert "\\uD800" in _lone_surrogate_message(0xD800) + assert "\\uDFFF" in _lone_surrogate_message(0xDFFF) + + +class TestFirstDomainViolationScan: + """Unit tests for the one tree walk behind both value-level refusals. + + The end-to-end tests above can only observe the first refusal; these + pin the traversal directly, including the cases where "which one is + first" is the whole question. The walk returns the ``Err`` sentence + itself rather than a code point or a float, so a caller cannot pair + a found violation with the wrong message, and the browser's twin + returns the same kind of thing. + """ + + def test_returns_none_for_clean_trees(self) -> None: + assert first_domain_violation(None) is None + assert first_domain_violation(True) is None + assert first_domain_violation(1.5) is None + assert first_domain_violation(0.0) is None + assert first_domain_violation(1) is None + assert first_domain_violation("plain") is None + assert first_domain_violation("\U0001F600") is None + assert first_domain_violation([1.0, "a", {"b": "c"}]) is None + assert first_domain_violation({"k": ["nested", {"deep": "ok"}]}) is None + + def test_finds_a_lone_surrogate_in_a_value(self) -> None: + assert first_domain_violation({"k": "a\ud800b"}) == ( + _lone_surrogate_message(0xD800) + ) + + def test_finds_a_lone_surrogate_in_a_key(self) -> None: + """Keys are strings too, and cross the same boundary. + + A walk that only visited values would leave the key route open — + and the key route is what #1308's own reproduction used. + """ + assert first_domain_violation({"a\ud800b": 1.0}) == ( + _lone_surrogate_message(0xD800) + ) + + def test_finds_a_non_finite_number(self) -> None: + assert first_domain_violation(float("inf")) == ( + _non_finite_number_message("Infinity") + ) + assert first_domain_violation(float("-inf")) == ( + _non_finite_number_message("-Infinity") + ) + assert first_domain_violation({"a": [1.0, float("inf")]}) == ( + _non_finite_number_message("Infinity") + ) + + def test_nan_is_covered_though_no_json_text_decodes_to_one(self) -> None: + """The third non-finite float, for completeness of the helper. + + No RFC 8259 number literal decodes to NaN and the bare constant + is refused at the parse gate, so this arm is unreachable through + ``json_parse`` — which is exactly why it needs a direct test: + an unreachable branch is where a wrong answer survives. + """ + assert first_domain_violation(float("nan")) == ( + _non_finite_number_message("NaN") + ) + + def test_an_integer_in_range_is_not_a_violation(self) -> None: + """An in-range ``int`` passes; an out-of-range one does not. + + This test asserted ``first_domain_violation(10**400) is None`` + until the integer arm landed. It read "a Python ``int`` cannot + be infinite" as "an ``int`` is always in the domain" — the exact + premise the float-only range check was built on, written down + twice and therefore agreeing with itself. A test derived from + the implementation's own reasoning does not miss the defect, it + certifies it; the refusals live in + ``TestIntegerOverflowRefusal1306``. + """ + assert first_domain_violation(1) is None + assert first_domain_violation(-1) is None + assert first_domain_violation(10**300) is None + assert first_domain_violation([1, 2, 3]) is None + assert first_domain_violation(10**400) == ( + _non_finite_number_message("Infinity") + ) + + def test_a_bool_is_not_read_as_a_number(self) -> None: + """``bool`` subclasses ``int``, not ``float``.""" + assert first_domain_violation([True, False]) is None + + def test_key_is_checked_before_its_own_value(self) -> None: + assert first_domain_violation({"\ud800": "\udc00"}) == ( + _lone_surrogate_message(0xD800) + ) + + def test_earlier_entry_wins_over_later(self) -> None: + assert first_domain_violation({"a": "\udc00", "b": "\ud800"}) == ( + _lone_surrogate_message(0xDC00) + ) + + def test_array_order_is_document_order(self) -> None: + assert first_domain_violation(["ok", "\udfff", "\ud800"]) == ( + _lone_surrogate_message(0xDFFF) + ) + + def test_the_two_kinds_share_one_document_order(self) -> None: + """Whichever comes first names the refusal — no precedence table.""" + assert first_domain_violation(["\ud800", float("inf")]) == ( + _lone_surrogate_message(0xD800) + ) + assert first_domain_violation([float("inf"), "\ud800"]) == ( + _non_finite_number_message("Infinity") + ) + + def test_boundary_code_points(self) -> None: + """The surrogate block is D800-DFFF inclusive on both ends. + + D7FF and E000 are ordinary scalar values that a range test with + the wrong comparison would reject. + """ + assert first_domain_violation("\ud7ff") is None + assert first_domain_violation("\ue000") is None + assert first_domain_violation("\ud800") == ( + _lone_surrogate_message(0xD800) + ) + assert first_domain_violation("\udfff") == ( + _lone_surrogate_message(0xDFFF) + ) diff --git a/tests/test_runtime_traps.py b/tests/test_runtime_traps.py index 80494439..9becc346 100644 --- a/tests/test_runtime_traps.py +++ b/tests/test_runtime_traps.py @@ -36,7 +36,11 @@ import pytest from vera.cli import cmd_run -from vera.runtime.traps import WasmTrapError, _classify_trap +from vera.runtime.traps import ( + WasmTrapError, + _classify_host_error, + _classify_trap, +) if TYPE_CHECKING: from _pytest.capture import CaptureFixture @@ -1578,6 +1582,7 @@ def test_fix_paragraph_table_covers_every_known_kind(self) -> None: expected_kinds = { "contract_violation", "divide_by_zero", + "host_error", "out_of_bounds", "stack_exhausted", "unreachable", @@ -2875,3 +2880,277 @@ def test_oob_reason_classifies_as_out_of_bounds_kind(self) -> None: assert fix else: # pragma: no cover - the guard must fire pytest.fail("expected an out-of-bounds WasmtimeError") + + +class TestHostCallbackErrorSurface1302: + """A host callback's exception is a Vera error, not a traceback (#1302). + + ``execute()`` used to convert an escaping exception into + ``WasmTrapError`` only when its type name was ``Trap`` or + ``WasmtimeError``. A host import that raises an ordinary Python + exception — ``json_stringify`` refusing a non-finite ``JNumber`` + (#1293) is the case that surfaced it — is re-raised through + wasmtime's trampoline and arrives at that handler as, say, a + ``ValueError``, so the branch was skipped entirely: no + classification, no source-map resolution, and the buffered + stdout/stderr dropped on the way out. The CLI's catch-all then let + the exception escape as a raw interpreter traceback. + + The invariant that broke is written down in ``vera/codegen/api.py``, + on ``host_print``: *"A user-level program must never produce a + Python traceback regardless of what it does."* The refusal itself + is correct and is an instruction (DESIGN principle 1); only its + presentation was wrong. + + The gap is generic — any host callback raising a non-``Trap`` + exception took the same path — so the classification is by + *boundary*, not by exception type: everything escaping the guest + invocation is either a wasmtime trap or a host-callback failure, and + both now reach the user in the same shape. + """ + + # `json_stringify` of a NaN built in Vera, not parsed. #1306 closed + # BOTH parse routes into this refusal — the bare constants and a + # number that overflows to an infinity — so a constructed `JNumber` + # is the one remaining way a user program reaches it, which is also + # exactly the program #1302 measured. The classifier's reach is not + # limited to this route: it converts anything escaping the guest + # invocation, and the unit tests below drive it directly. + _SRC = """ +public fn main(@Unit -> @Unit) + requires(true) ensures(true) effects() +{ + IO.print("before"); + IO.print(json_stringify(JNumber(nan()))) +} +""" + + def _write(self, tmp_path: Path) -> Path: + path = tmp_path / "hosterr.vera" + path.write_text(self._SRC, encoding="utf-8") + return path + + def test_execute_raises_a_classified_wasm_trap_error( + self, tmp_path: Path, + ) -> None: + """``execute()`` converts it, rather than letting it escape.""" + from vera.codegen import compile as codegen_compile, execute + from vera.parser import parse_file + from vera.transform import transform + + path = self._write(tmp_path) + source = path.read_text(encoding="utf-8") + result = codegen_compile( + transform(parse_file(str(path))), source=source, file=str(path), + ) + assert result.ok + + with pytest.raises(WasmTrapError) as excinfo: + execute(result) + + exc = excinfo.value + assert exc.kind == "host_error" + # The host's own sentence survives verbatim — it is the + # instruction the user needs, and #1293 wrote it deliberately. + assert "json_stringify: NaN is not representable in JSON" in str(exc) + # #522: output written before the failure is carried, not + # dropped. This is the field the pre-fix path discarded. + assert exc.stdout == "before" + # The original exception stays reachable for anyone debugging + # the host binding itself. + assert isinstance(exc.__cause__, ValueError) + + def test_text_mode_prints_a_vera_error_not_a_traceback( + self, tmp_path: Path, capsys: CaptureFixture[str], + ) -> None: + """The CLI's standard error shape, and nothing from CPython. + + Asserting only "the sentence appears" would still pass on the + pre-fix output, where the sentence was the traceback's last + line. The absence assertions are the ones that fail before the + fix. + """ + rc = cmd_run(str(self._write(tmp_path))) + + assert rc == 1 + captured = capsys.readouterr() + assert "before" in captured.out + assert "Error: json_stringify: NaN is not representable" in captured.err + assert "Traceback (most recent call last)" not in captured.err + assert 'File "' not in captured.err + assert "wasmtime" not in captured.err + # The whole diagnostic, in the shape a contract violation uses. + assert len(captured.err.splitlines()) < 10, captured.err + + def test_json_mode_emits_a_parseable_envelope( + self, tmp_path: Path, capsys: CaptureFixture[str], + ) -> None: + """JSON mode produced NO envelope at all before the fix. + + The traceback went to stderr and stdout stayed empty, so a + ``--json`` consumer got nothing parseable — worse than the text + mode, where at least the tee'd program output survived. + """ + rc = cmd_run(str(self._write(tmp_path)), as_json=True) + + assert rc == 1 + captured = capsys.readouterr() + envelope = json.loads(captured.out) + assert envelope["ok"] is False + diag = envelope["diagnostics"][0] + assert diag["trap_kind"] == "host_error" + assert "json_stringify: NaN is not representable" in diag["description"] + # Shape stability: the fields every trap diagnostic carries. + assert diag["fix"] == "" + assert isinstance(diag["frames"], list) + # #522 again, through the envelope this time. + assert envelope["stdout"] == "before" + # JSON-mode invariant: nothing may leak to stderr. + assert captured.err == "" + + +class TestHostErrorDebugKnob1302: + """``VERA_DEBUG_HOST_ERRORS`` re-raises the original exception (#1302). + + Converting every host-callback exception into a `WasmTrapError` is + right for a user running a Vera program and wrong for someone + debugging the host binding itself: the description keeps the + sentence, but the Python frames that say *where in the binding* it + came from are gone from the CLI's output. They are still on + `__cause__`, which helps a library caller and not a person reading a + terminal. + + The knob restores the raw traceback, and follows the + ``VERA_EAGER_GC`` precedent — a documented diagnostic switch in + ENVIRONMENT.md, not a supported mode. The two tests are a pair on + purpose: one proves the knob does something, the other proves its + absence is what produces the one-liner, so neither can pass by the + behaviour being unconditional. + """ + + _SRC = TestHostCallbackErrorSurface1302._SRC + + def _compile(self, tmp_path: Path) -> object: + from vera.codegen import compile as codegen_compile + from vera.parser import parse_file + from vera.transform import transform + + path = tmp_path / "knob.vera" + path.write_text(self._SRC, encoding="utf-8") + source = path.read_text(encoding="utf-8") + result = codegen_compile( + transform(parse_file(str(path))), source=source, file=str(path), + ) + assert result.ok + return result + + def test_the_knob_re_raises_the_original_exception( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + ) -> None: + from vera.codegen import execute + + monkeypatch.setenv("VERA_DEBUG_HOST_ERRORS", "1") + result = self._compile(tmp_path) + with pytest.raises(ValueError) as excinfo: + execute(result) # type: ignore[arg-type] + assert not isinstance(excinfo.value, WasmTrapError) + assert "json_stringify: NaN is not representable" in str(excinfo.value) + + def test_without_the_knob_the_conversion_still_happens( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The other half of the pair. + + Without this, a knob that was read as always-on — or a + conversion accidentally deleted — would look identical to a + working knob from the test above alone. + """ + from vera.codegen import execute + + monkeypatch.delenv("VERA_DEBUG_HOST_ERRORS", raising=False) + result = self._compile(tmp_path) + with pytest.raises(WasmTrapError) as excinfo: + execute(result) # type: ignore[arg-type] + assert excinfo.value.kind == "host_error" + + @pytest.mark.parametrize( + ("value", "enabled"), + [("1", True), ("true", True), ("TRUE", True), ("yes", True), + (" 1 ", True), ("0", False), ("", False), ("no", False), + ("false", False)], + ) + def test_the_knob_accepts_the_same_spellings_as_vera_eager_gc( + self, value: str, enabled: bool, tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """One truthiness rule across the ``VERA_*`` diagnostic knobs. + + ``VERA_EAGER_GC`` accepts ``1`` / ``true`` / ``yes`` + case-insensitively after stripping; a second knob reading its + value differently is a trap for anyone who learned the first. + """ + from vera.codegen import execute + + monkeypatch.setenv("VERA_DEBUG_HOST_ERRORS", value) + result = self._compile(tmp_path) + expected: type[BaseException] = ValueError if enabled else WasmTrapError + with pytest.raises(expected): + execute(result) # type: ignore[arg-type] + + def test_the_cli_prints_a_traceback_under_the_knob( + self, tmp_path: Path, capsys: CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """End to end: the knob is what a person debugging would set. + + The exception escapes ``cmd_run``'s ``WasmTrapError`` handler and + the interpreter prints its own traceback, which is the point — + so this asserts the raise reaches the caller rather than + capturing stderr the CLI never writes. + """ + monkeypatch.setenv("VERA_DEBUG_HOST_ERRORS", "1") + path = tmp_path / "knob_cli.vera" + path.write_text(self._SRC, encoding="utf-8") + with pytest.raises(ValueError): + cmd_run(str(path)) + + +class TestClassifyHostError1302: + """Unit tests for the host-callback classifier (#1302).""" + + def test_uses_the_exception_message_as_the_description(self) -> None: + kind, description, fix = _classify_host_error( + ValueError("json_stringify: NaN is not representable in JSON"), + ) + assert kind == "host_error" + assert description == ( + "json_stringify: NaN is not representable in JSON" + ) + # Same rule as ``contract_violation``: the message already is + # the instruction, so a canned paragraph under it is noise. + assert fix == "" + + def test_falls_back_to_the_exception_type_when_there_is_no_message( + self, + ) -> None: + """An empty ``str(exc)`` must not produce an empty description. + + ``raise RuntimeError()`` inside a binding would otherwise render + as ``Error: `` — a line that tells the user nothing at all. + """ + _kind, description, _fix = _classify_host_error(RuntimeError()) + assert description == "RuntimeError" + + def test_does_not_consult_the_contract_violation_channel(self) -> None: + """Host errors are classified on their own, not via ``_classify_trap``. + + ``_classify_trap`` lets a populated ``last_violation`` win over + everything else. Routing host errors through it would let a + stale contract message replace the host's sentence, which is the + one piece of information the user actually needs here. + """ + kind, description, _fix = _classify_host_error( + ValueError("the host's own sentence"), + ) + assert kind == "host_error" + assert description == "the host's own sentence" diff --git a/vera/README.md b/vera/README.md index 3cc60f4e..aed9e7e5 100644 --- a/vera/README.md +++ b/vera/README.md @@ -113,7 +113,7 @@ execute(compile_result, ...) # → run WASM via wasmtime | ` ├ closures.py` | 582 | | Closures, anonymous functions, free variable analysis | | | ` ├ data.py` | 1,515 | | Constructors, match expressions (incl. nested patterns), arrays, indexing | | | ` ├ markdown.py` | 651 | | WASM memory marshalling for MdInline/MdBlock ADTs | | -| ` ├ json_serde.py` | 420 | | WASM memory marshalling for Json ADT | | +| ` ├ json_serde.py` | 631 | | WASM memory marshalling for Json ADT | | | ` └ html_serde.py` | 261 | | WASM memory marshalling for HtmlNode ADT | | | `markdown.py` | 728 | Compile | Python Markdown parser/renderer (§9.7.3 subset) | `parse_markdown()`, `render_markdown()`, `has_heading()`, `has_code_block()`, `extract_code_blocks()` | | `obligations/` | 785 | Verify | Reified proof obligations + warm incremental session (#222 A/B) | `ProofObligation`, `VerificationSession` | @@ -142,7 +142,7 @@ execute(compile_result, ...) # → run WASM via wasmtime | ` compilability.py` | 1,004 | | Compilability checks; the two host-import pre-scans (State/Exn families and IO/Markdown/Regex builtins), walking each function's body, its contract predicates and every signature the guard layer will check — including closures', cycle-guarded | | | ` wasi.py` | 4,828 | | WASI Preview 2 component/adapter emitter — `--target wasi-p2` / `--world server` (#237, #853) | | | `runtime/` | 4,817 | Execute | wasmtime host layer (#421): traps + per-effect host-binding families | `register_*()`, `WasmTrapError` | -| ` traps.py` | 493 | | `WasmTrapError`, `_classify_trap`, source-backtrace resolution | | +| ` traps.py` | 493 | | `WasmTrapError`, `_classify_trap` / `_classify_host_error`, source-backtrace resolution | | | ` heap.py` | 1,376 | | WASM memory marshalling primitives, ADT/Option/Array/bucket codecs, `_ShadowGuard`, shared collection helpers | | | ` collections.py` | 16 | | `_VAL_WASM_TYPES` value-type dispatch table (shared by Map/Set) | | | ` text.py` | 34 | | `safe_utf8_decode` — the single lossy-decode site (#592) | | @@ -589,7 +589,7 @@ The WASM import interface is the portability contract: the compiled `.wasm` bina ### Browser runtime -`browser/runtime.mjs` is a self-contained JavaScript runtime (~3,303 lines) that provides JavaScript implementations of all Vera host bindings. It works with **any** compiled Vera `.wasm` module — no code generation needed. +`browser/runtime.mjs` is a self-contained JavaScript runtime (~3,810 lines) that provides JavaScript implementations of all Vera host bindings. It works with **any** compiled Vera `.wasm` module — no code generation needed. **Dynamic import introspection:** Instead of generating per-program glue code, the runtime uses `WebAssembly.Module.imports(module)` at initialization to discover which host functions the module actually needs, then builds the import object dynamically. State\ types are pattern-matched from `state_get_*`/`state_put_*` import names. @@ -599,7 +599,7 @@ The WASM import interface is the portability contract: the compiled `.wasm` bina **GC reachability discipline (JS host side):** JS host functions that allocate multiple WASM heap blocks and hold intermediates in JS locals must root those intermediates on the shadow stack — otherwise EAGER_GC (and, under pressure, normal GC) reclaims them mid-walk. The runtime exports two helpers: `gcShadowPush(ptr)` writes a pointer to `$gc_sp` and advances it (throws if `$gc_sp` / `$gc_stack_limit` aren't exported, since that means the module was built without GC support but is calling allocators that can trigger GC), and `gcGuard(fn)` saves `$gc_sp` at entry and restores it on exit (success or exception). This is the browser parallel of the CLI-side `_ShadowGuard` context manager added in v0.0.158 (#692). The walkers `writeJson` / `writeHtml` and the parsers `json_parse` / `html_parse` wrap their bodies in `gcGuard` and push intermediates (`arrPtr`, `wrapperPtr`, `jsonPtr`) as soon as each is allocated — see `runtime.mjs` for the canonical pattern. Without this, `Map` / `Set` and similar heap-pointer-keyed collections drop values under GC pressure (#708). -**Parity enforcement:** `tests/test_browser.py` runs the examples the browser target can execute — two explicit lists in that file, not the whole `examples/` directory, since an example that reads stdin interactively, uses a refused host family (file IO, `DB`), or does not compile standalone cannot be compared — plus per-binding batteries over the Map/Set/Decimal/Json/Regex/Markdown host imports, through both Python/wasmtime and Node.js/JS-runtime. The two example lists carry different oracles: the examples exporting `main` are run and compared on stdout, while the ones reached as exported functions are called with fixed arguments and compared on the returned value. The per-binding batteries compare stdout. `json_stringify` and `md_render` are compared the same way and additionally against the canonical form the specification states for each (§9.7.1, §9.7.3), because cross-host equality alone would be satisfied by two hosts agreeing on a wrong answer; `md_render` is also asserted stable under re-render and exercised on `MdBlock` values the test *builds*, since several renderer rules are unreachable through `md_parse`, and `json_stringify`'s number rendering is checked differentially against a real `JSON.stringify`. The two *parsers* are where the hosts still differ, and each difference is tracked. `md_parse` diverges most widely — plain-text run grouping inside a paragraph, and a handful of block markers §9.7.3 does not pin — tracked as [#1301](https://github.com/aallan/vera/issues/1301). `json_parse` diverges only outside RFC 8259: the reference host accepts `NaN` and the infinities where the browser refuses them ([#1306](https://github.com/aallan/vera/issues/1306)), and a lone-surrogate escape becomes U+FFFD in the browser where the reference host fails outright ([#1308](https://github.com/aallan/vera/issues/1308)) — every well-formed document parses the same on both. For both parsers the suite pins the inputs the two do agree on. The browser stubs are covered on two different shapes: `IO.read_file` and `IO.write_file` get the same per-host pinning, run through both runtimes against a path that really is readable or writable so the native `Ok` and the browser `Err` are each asserted (a missing file or an unwritable directory would `Err` on both sides and prove nothing), while `IO.read_char` is exercised in Node alone — the module links and the stub's `Err` arm returns `0` — with no native run to compare against. `Inference` and `DB` return `Err` from every browser operation, which is a deliberate platform boundary — the credentials they need would be readable from page source — rather than a divergence awaiting a fix. Pre-commit hooks and CI trigger these tests on any change to the host binding surface. +**Parity enforcement:** `tests/test_browser.py` runs the examples the browser target can execute — two explicit lists in that file, not the whole `examples/` directory, since an example that reads stdin interactively, uses a refused host family (file IO, `DB`), or does not compile standalone cannot be compared — plus per-binding batteries over the Map/Set/Decimal/Json/Regex/Markdown host imports, through both Python/wasmtime and Node.js/JS-runtime. The two example lists carry different oracles: the examples exporting `main` are run and compared on stdout, while the ones reached as exported functions are called with fixed arguments and compared on the returned value. The per-binding batteries compare stdout. `json_stringify` and `md_render` are compared the same way and additionally against the canonical form the specification states for each (§9.7.1, §9.7.3), because cross-host equality alone would be satisfied by two hosts agreeing on a wrong answer; `md_render` is also asserted stable under re-render and exercised on `MdBlock` values the test *builds*, since several renderer rules are unreachable through `md_parse`, and `json_stringify`'s number rendering is checked differentially against a real `JSON.stringify`. `json_parse` is compared by accepted domain, the parse-side counterpart: §9.7.1 states the domain — RFC 8259-valid text that decodes to finite numbers and strings of Unicode scalar values — and the battery compares the whole `Err` message across hosts for the JavaScript constants and for a lone-surrogate escape, parameterised over every position a string can occupy (value, key, array element, nested), beside controls the refusals must not disturb: matched surrogate pairs, and `"NaN"` as an ordinary string value. `md_parse` is the one parser still diverging — plain-text run grouping inside a paragraph, and a handful of block markers §9.7.3 does not pin — tracked as [#1301](https://github.com/aallan/vera/issues/1301); for it the suite pins the inputs the two do agree on. The browser stubs are covered on two different shapes: `IO.read_file` and `IO.write_file` get the same per-host pinning, run through both runtimes against a path that really is readable or writable so the native `Ok` and the browser `Err` are each asserted (a missing file or an unwritable directory would `Err` on both sides and prove nothing), while `IO.read_char` is exercised in Node alone — the module links and the stub's `Err` arm returns `0` — with no native run to compare against. `Inference` and `DB` return `Err` from every browser operation, which is a deliberate platform boundary — the credentials they need would be readable from page source — rather than a divergence awaiting a fix. Pre-commit hooks and CI trigger these tests on any change to the host binding surface. `browser/emit.py` provides `emit_browser_bundle()` for the `vera compile --target browser` CLI command, which produces a ready-to-serve directory (module.wasm + vera-runtime.mjs + index.html). diff --git a/vera/browser/runtime.mjs b/vera/browser/runtime.mjs index 063d169c..92e4f758 100644 --- a/vera/browser/runtime.mjs +++ b/vera/browser/runtime.mjs @@ -2954,6 +2954,195 @@ function buildImportObject(module, moduleBytes) { return parts.join(""); } + // ── json_parse's accept domain (spec §9.7.1) ────────────────── + // + // ``json_parse`` accepts exactly RFC 8259-valid text that decodes to + // finite numbers and strings of Unicode scalar values; everything + // else is a handled Err, identically on both hosts, at the parse. The domain is Vera's + // own, not whatever the host parser happens to implement — so each + // exclusion needs an explicit gate on the side whose parser does not + // already enforce it. + // + // * the bare JavaScript constants: ``JSON.parse`` refuses them for + // free here, where Python's ``json.loads`` does not — which is + // what the reference host's ``parse_constant`` hook is for. The + // only work on this side is naming the refusal in the shared + // sentence rather than in ECMAScript's syntax message. + // * a lone-surrogate escape, and a number that overflows to an + // infinity: BOTH parsers accept these texts, so both gates are + // this host's to enforce as much as the reference host's, and + // both are decided on the decoded value by one walk. + // + // All three sentences are hand-copied from ``vera/wasm/json_serde.py`` + // (``_non_finite_parse_message`` / ``_lone_surrogate_message`` / + // ``_non_finite_number_message``) and held against those originals by + // tests/test_browser.py. + + const NON_FINITE_TOKENS = ["-Infinity", "Infinity", "NaN"]; + + function nonFiniteParseMessage(name) { + return ( + `json_parse: ${name} is not valid JSON — RFC 8259 has no NaN or ` + + `Infinity. json_parse accepts RFC 8259 text only, not the ` + + `JavaScript constants: quote the value as a string, or write null.` + ); + } + + function nonFiniteNumberMessage(name) { + return ( + `json_parse: a number in the text overflows to ${name}, which JSON ` + + `cannot represent — RFC 8259 §6 lets an implementation set limits ` + + `on the range of numbers it accepts, and Vera's accepted range is ` + + `the finite Float64 values. Keep the magnitude at or below ` + + `1.7976931348623157e308, or carry the value as a string.` + ); + } + + function loneSurrogateMessage(codePoint) { + const hex = codePoint.toString(16).toUpperCase().padStart(4, "0"); + return ( + `json_parse: \\u${hex} decodes to a lone surrogate, which ` + + `is not a Unicode scalar value — a Vera string is a sequence of ` + + `scalar values, so this text has no representable decoding. Write ` + + `the character as a matched high-then-low surrogate escape pair, or ` + + `remove the escape.` + ); + } + + // Replace every bare NaN / Infinity / -Infinity with ``0``, reporting + // the first one replaced. String literals are copied through + // untouched, so ``{"k":"NaN"}`` — ordinary JSON — is not a candidate. + // + // Used ONLY after JSON.parse has already rejected the text, so it + // cannot widen the accept domain; it only decides which sentence + // explains the refusal. Naming the FIRST constant in document order + // matches the reference host, whose ``parse_constant`` hook is + // called left to right and records the first it is handed. + // A token only counts where a VALUE may begin: at the start of the + // text, or after '[', ',' or ':'. Whitespace does not move that. + // Without the constraint the scan found NaN at offset 1 of "-NaN", + // substituted, re-parsed "-0" successfully and reported the shared + // sentence — where the reference host, whose parser never reaches the + // token at all, gives a plain syntax error. Note '{' is NOT a + // value-start: what may follow it is a key. + function stripBareNonFinite(text) { + let out = ""; + let first = null; + let i = 0; + let atValueStart = true; + while (i < text.length) { + const c = text[i]; + if (c === '"') { + const start = i; + i++; + while (i < text.length) { + if (text[i] === "\\") { i += 2; continue; } + if (text[i] === '"') { i++; break; } + i++; + } + out += text.slice(start, i); + atValueStart = false; + continue; + } + // RFC 8259 §2 whitespace: space, tab, LF, CR. + if (c === " " || c === "\t" || c === "\n" || c === "\r") { + out += c; + i++; + continue; + } + if (atValueStart) { + const token = NON_FINITE_TOKENS.find((t) => text.startsWith(t, i)); + if (token !== undefined) { + if (first === null) first = token; + out += "0"; + i += token.length; + atValueStart = false; + continue; + } + } + atValueStart = (c === "[" || c === "," || c === ":"); + out += c; + i++; + } + return { first, text: out }; + } + + // The first lone surrogate code unit in a JS string, or null. + // + // A JS string is UTF-16, so a paired astral character is STORED as + // two surrogate code units and is perfectly representable — the pair + // has to be consumed whole before anything is judged lone, or every + // emoji in every document would be refused. The reference host's + // twin needs no pairing step: ``json.loads`` has already combined a + // well-formed escape pair into one astral code point, so a plain + // range test is complete there. Same rule, two representations of + // the decoded value. + function firstLoneSurrogateInString(s) { + for (let i = 0; i < s.length; i++) { + const unit = s.charCodeAt(i); + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = i + 1 < s.length ? s.charCodeAt(i + 1) : 0; + if (next >= 0xdc00 && next <= 0xdfff) { i++; continue; } + return unit; + } + if (unit >= 0xdc00 && unit <= 0xdfff) return unit; + } + return null; + } + + // One walk over a parseJsonOrdered tree for both value-level + // exclusions — a string holding a lone surrogate, and a number that + // is not finite — returning the Err sentence itself, exactly as + // ``first_domain_violation`` does on the reference host. Document + // order means, for an object, each key before its own value; one + // traversal for both kinds is what makes "whichever comes first names + // the refusal" the rule rather than a precedence table the two hosts + // could implement differently. Keys are checked as well as values: a + // key crosses the WASM boundary as a string exactly like a value does. + // + // ``1e999`` is where the number arm earns its place: syntactically + // valid RFC 8259 that JSON.parse accepts, decoding to Infinity, which + // the bare-constant gate above never sees because the text IS valid + // JSON. Same exclusion, a different entry route, its own sentence. + function firstDomainViolation(node) { + if (typeof node === "string") { + const codePoint = firstLoneSurrogateInString(node); + return codePoint === null ? null : loneSurrogateMessage(codePoint); + } + if (typeof node === "number") { + if (Number.isFinite(node)) return null; + // NaN is unreachable from here — JSON.parse rejects the bare + // constant before this walk runs, and no numeric literal decodes + // to one — but it is named rather than folded into the negative + // branch, because ``node > 0`` is false for NaN and would report + // "-Infinity". The reference host's twin gets the name from + // ``_NON_FINITE_NAMES``, which covers all three; a host that + // answers differently on a case neither can reach today is a + // divergence waiting for the day one of them can. + const name = Number.isNaN(node) + ? "NaN" + : (node > 0 ? "Infinity" : "-Infinity"); + return nonFiniteNumberMessage(name); + } + if (Array.isArray(node)) { + for (const item of node) { + const found = firstDomainViolation(item); + if (found !== null) return found; + } + return null; + } + if (node instanceof Map) { + for (const [key, item] of node) { + const codePoint = firstLoneSurrogateInString(String(key)); + if (codePoint !== null) return loneSurrogateMessage(codePoint); + const found = firstDomainViolation(item); + if (found !== null) return found; + } + return null; + } + return null; + } + if (needed.has("json_parse")) { imports.vera.json_parse = (ptr, len) => { const text = readString(ptr, len); @@ -2962,12 +3151,40 @@ function buildImportObject(module, moduleBytes) { try { JSON.parse(text); } catch (e) { + // #1306: if the ONLY thing wrong with the text is a bare + // JavaScript constant, both hosts say so in one sentence. The + // stripped text is handed back to JSON.parse rather than + // trusted: a token that merely looks like one (``Infinity_x``) + // leaves the document malformed, so it falls through to the + // host parser's own syntax message, which is what every other + // malformed input has always reported. + const probe = stripBareNonFinite(text); + if (probe.first !== null) { + let strippedParses = true; + try { JSON.parse(probe.text); } catch { strippedParses = false; } + if (strippedParses) { + return allocResultErrString(nonFiniteParseMessage(probe.first)); + } + } return allocResultErrString(e.message || String(e)); } // #1293: JSON.parse decided whether the text is JSON; its result // is discarded because it cannot carry key order. The tree the // ADT is built from comes from the order-preserving re-scan. const parsed = parseJsonOrdered(text); + // The value-level half of the domain, checked on the decoded + // VALUE and before anything crosses into WASM memory. A lone + // surrogate (#1308) has no UTF-8 encoding and no Vera string can + // hold one: past this point writeJson reaches allocString, whose + // TextEncoder silently substituted U+FFFD — a different value + // than the text encoded, with nothing to tell the caller. A + // number that overflowed to an infinity (#1306) would reach + // json_stringify's refusal instead, one call too late and on a + // value the domain says never gets in. + const violation = firstDomainViolation(parsed); + if (violation !== null) { + return allocResultErrString(violation); + } // #708 (PR #707): wrap in gcGuard and push jsonPtr // before allocResultOkI32's alloc can fire GC. writeJson // has its own internal guard that pops on return — by the diff --git a/vera/codegen/api.py b/vera/codegen/api.py index d776d5d2..6fc2c1c6 100644 --- a/vera/codegen/api.py +++ b/vera/codegen/api.py @@ -57,6 +57,7 @@ WasmTrapError as WasmTrapError, # re-export: part of execute()'s contract ) from vera.runtime.traps import ( + _classify_host_error, _classify_trap, _resolve_trap_frames, ) @@ -1307,6 +1308,19 @@ def _alloc_string_arg(s: str) -> tuple[int, int]: # was relabelled "Runtime contract violation"). # WasmTrapError is a RuntimeError subclass, so existing # ``except RuntimeError`` blocks remain backward-compatible. + # #1302 — the conversion is keyed on the BOUNDARY, not on the + # exception's type. The guarded region is the guest invocation + # and nothing else, so anything arriving here is either a + # wasmtime trap or a host callback that raised: wasmtime's + # trampoline re-raises a host-import exception unchanged, and + # every compiler phase has already finished. Keying on the type + # name instead meant a ``ValueError`` from a host binding — a + # deliberate refusal like ``json_stringify``'s on a non-finite + # number — skipped the branch entirely and escaped as a raw + # Python traceback, dropping the captured streams on the way + # out. ``host_print``'s invariant ("a user-level program must + # never produce a Python traceback regardless of what it does") + # holds for every host import, not just the decoding ones. exc_name = type(exc).__name__ if exc_name in ("Trap", "WasmtimeError"): # #516 Stage 3 (#547) — _classify_trap now returns @@ -1317,22 +1331,38 @@ def _alloc_string_arg(s: str) -> tuple[int, int]: kind, message, fix = _classify_trap( exc, last_violation, last_overflow, ) - # #516 Stage 2 — resolve trap frames against the source map. - # Pre-Stage-2 the user got a hex-offset wasmtime backtrace - # in the exception message and nothing else; now they get - # a structured list of (file, line) pairs they can act on. - frames = _resolve_trap_frames( - exc, result.fn_source_map, result.prelude_fn_names, - ) - raise WasmTrapError( - message, - stdout=output_buf.getvalue(), - stderr=stderr_buf.getvalue() if stderr_buf is not None else "", - kind=kind, - frames=frames, - fix=fix, - ) from exc - raise + else: + # Diagnostic escape hatch (ENVIRONMENT.md, + # ``VERA_DEBUG_HOST_ERRORS``). The conversion keeps the + # sentence and drops the Python frames, which is right for + # someone running a Vera program and wrong for someone + # debugging the host binding itself — the frames survive on + # ``__cause__``, which serves a library caller and not a + # person reading a terminal. Setting the variable re-raises + # the original untouched. Same truthiness rule as + # ``VERA_EAGER_GC``, the other ``VERA_*`` diagnostic knob. + if os.environ.get("VERA_DEBUG_HOST_ERRORS", "").strip().lower() in ( + "1", "true", "yes", + ): + raise + kind, message, fix = _classify_host_error(exc) + # #516 Stage 2 — resolve trap frames against the source map. + # Pre-Stage-2 the user got a hex-offset wasmtime backtrace + # in the exception message and nothing else; now they get + # a structured list of (file, line) pairs they can act on. + # A host-callback exception usually carries no frames at all, + # which resolves to the empty list the field already allows. + frames = _resolve_trap_frames( + exc, result.fn_source_map, result.prelude_fn_names, + ) + raise WasmTrapError( + message, + stdout=output_buf.getvalue(), + stderr=stderr_buf.getvalue() if stderr_buf is not None else "", + kind=kind, + frames=frames, + fix=fix, + ) from exc # Extract return value value: int | float | str | None diff --git a/vera/runtime/json.py b/vera/runtime/json.py index c28a925d..98038706 100644 --- a/vera/runtime/json.py +++ b/vera/runtime/json.py @@ -29,17 +29,78 @@ def register_json(linker: wasmtime.Linker, ops_used: set[str]) -> None: """Register the requested JSON host functions on `linker`.""" import json as _json - from vera.wasm.json_serde import dumps_canonical, read_json, write_json + from vera.wasm.json_serde import ( + _non_finite_parse_message, + dumps_canonical, + first_domain_violation, + read_json, + write_json, + ) if "json_parse" in ops_used: def host_json_parse( caller: wasmtime.Caller, ptr: int, length: int, ) -> int: + # json_parse accepts exactly RFC 8259-valid text that + # decodes to finite numbers and strings of Unicode scalar + # values (spec §9.7.1); everything else is a handled Err, + # identically on both hosts, at the parse. Vera defines + # that domain — it does not inherit Python's. The two gates + # below are what ``json.loads`` alone would not enforce; the + # browser's twin sentences live in + # ``vera/browser/runtime.mjs``. text = _read_wasm_string(caller, ptr, length) + + # #1306. Python's default ``parse_constant`` maps ``NaN`` / + # ``Infinity`` / ``-Infinity`` onto the matching floats; RFC + # 8259 has no such literals and ``JSON.parse`` refuses them. + # + # The hook RECORDS rather than raises, and the refusal is + # decided after the parse finishes. Raising immediately + # would make the reference host answer a *different + # question* from the browser: Python's scanner calls the + # hook as soon as it sees the token, so ``[Infinity_x]`` — + # malformed for a reason that has nothing to do with the + # constant — would report the non-finite sentence here while + # the browser reported a syntax error. Recording and + # continuing asks what the browser asks: *would this text be + # valid JSON if the constants were admitted?* Only then is + # the constant the whole story, and only then do both hosts + # say the same sentence. The placeholder is inert — the + # refusal below fires before anything is marshalled. + seen_constant: list[str] = [] + + def record_non_finite(name: str) -> float: + if not seen_constant: + seen_constant.append(name) + return 0.0 + try: - parsed = _json.loads(text) + parsed = _json.loads(text, parse_constant=record_non_finite) except (ValueError, TypeError) as exc: return _alloc_result_err_string(caller, str(exc)) + if seen_constant: + return _alloc_result_err_string( + caller, _non_finite_parse_message(seen_constant[0]), + ) + # The two value-level exclusions, in one document-order + # walk. A lone surrogate is not a Unicode scalar value, so + # it has no UTF-8 encoding and cannot become a Vera string + # at all (#1308). A number that overflowed to an infinity + # is the second entry route to a non-finite JNumber, the one + # the bare-constant gate above cannot see because the text + # is perfectly good RFC 8259 (#1306). + # + # Both are checked on the decoded VALUE, before anything + # crosses into WASM memory: past this point ``write_json`` + # reaches ``_alloc_string``, whose ``.encode("utf-8")`` is + # where the surrogate refusal used to arrive as a raw + # traceback, and ``json_stringify`` is where the overflow + # one used to arrive — a call too late, and as the same + # traceback (#1302). + violation = first_domain_violation(parsed) + if violation is not None: + return _alloc_result_err_string(caller, violation) # #692: hold the shadow-stack window open across the # full tree marshalling AND the final Result.Ok # wrapper alloc. ``guard.__exit__`` restores diff --git a/vera/runtime/server.py b/vera/runtime/server.py index 9d9d2156..e668692f 100644 --- a/vera/runtime/server.py +++ b/vera/runtime/server.py @@ -98,8 +98,12 @@ def _serve(self) -> None: env_vars=env_vars, ) except WasmTrapError as trap: - # Contract violation / runtime trap → 500 with the - # trap diagnostic; the connection is always answered. + # Contract violation, runtime trap, or a host import + # that raised (#1302 routes those here too, as + # ``kind="host_error"``) → 500 with the diagnostic; the + # connection is always answered. Before #1302 a host + # callback's exception escaped this handler entirely and + # the request went unanswered. payload = json.dumps({ "error": str(trap), "trap_kind": trap.kind, diff --git a/vera/runtime/traps.py b/vera/runtime/traps.py index d104cdbb..fd043ae3 100644 --- a/vera/runtime/traps.py +++ b/vera/runtime/traps.py @@ -102,6 +102,12 @@ class WasmTrapError(RuntimeError): * ``unreachable`` — ``unreachable`` instruction executed (the WASM panic primitive — typically a non-exhaustive match). * ``overflow`` — integer overflow trap. + * ``host_error`` — a host import (an effect operation + implemented outside WASM) raised rather than trapping; the + message is the host binding's own (#1302). Everything + escaping the guest invocation carries one of these kinds: + the conversion is keyed on the boundary, not on the + exception's type. * ``unknown`` — could not classify; raw wasmtime message in ``str()``. @@ -398,10 +404,45 @@ def _resolve_trap_frames( "variant via a helper function." ), "contract_violation": "", + "host_error": "", "unknown": "", } +def _classify_host_error(exc: BaseException) -> tuple[str, str, str]: + """Classify a host-callback exception into ``(kind, description, fix)``. + + The companion to :func:`_classify_trap` for the other half of what + can escape a guest invocation. A host import that raises an + ordinary Python exception — ``json_stringify`` refusing a non-finite + ``JNumber``, say — is re-raised through wasmtime's trampoline and + arrives at ``execute()``'s handler as that exception, not as a + ``Trap``. Before #1302 it fell past the conversion entirely and + reached the user as a raw interpreter traceback. + + The description is the exception's own message: a host binding that + refuses something states why and what to do about it (DESIGN + principle 1), so there is nothing to add and everything to lose by + paraphrasing. An exception with no message would render as an empty + line, so the type name stands in. + + The Fix paragraph is empty for the same reason it is empty for + ``contract_violation``: the description already carries the specific + instruction, and a canned paragraph beneath it would be noise. + + Unlike :func:`_classify_trap` this does not consult the + ``last_violation`` channel. That channel exists because a WASM trap + reason is less specific than the contract message the host recorded + just before it; here the host's message IS the specific one, and + letting a stale violation win would replace it. + """ + return ( + "host_error", + str(exc) or type(exc).__name__, + _TRAP_FIX_PARAGRAPHS["host_error"], + ) + + def _classify_trap( exc: BaseException, last_violation: list[str], diff --git a/vera/wasm/json_serde.py b/vera/wasm/json_serde.py index 2f145d48..194a2477 100644 --- a/vera/wasm/json_serde.py +++ b/vera/wasm/json_serde.py @@ -17,6 +17,19 @@ dumps_canonical(value) → str format_json_number(value) → str +Accept domain (spec §9.7.1, #1306 / #1308): + first_domain_violation(value) → str | None + _non_finite_parse_message(name) → str + _non_finite_number_message(name) → str + _lone_surrogate_message(code_point) → str + +The domain gates sit in front of the write direction rather than inside +it: `vera/runtime/json.py` consults them on the value `json.loads` +returned, before `write_json` marshals anything, and +`vera/browser/runtime.mjs` carries the twin of each. See the section +comment below for what the domain is and why it is stated rather than +inherited. + The text direction is the last mile of the read direction and lives here for that reason: ``json_stringify`` is ``read_json`` followed by ``dumps_canonical``. Its output form is canonical and shared with the @@ -62,6 +75,204 @@ _TAG_JOBJECT = 5 +# --------------------------------------------------------------------------- +# json_parse's accept domain (spec §9.7.1) +# --------------------------------------------------------------------------- +# +# ``json_parse`` accepts exactly RFC 8259-valid text that decodes to +# finite numbers and strings of Unicode scalar values; everything else +# is a handled ``Err``, +# identically on both hosts, at the parse. The domain is Vera's own — it +# is not inherited from whichever parser a host happens to call, which is +# why each of the two exclusions below needs an explicit gate on at least +# one side: +# +# * the JavaScript constants ``NaN`` / ``Infinity`` / ``-Infinity``, +# which RFC 8259 has no literals for. Python's ``json.loads`` admits +# them through ``parse_constant``; ``JSON.parse`` refuses them +# (#1306). +# * a lone surrogate, which is not a Unicode scalar value and has no +# UTF-8 encoding, so no Vera string can hold one. Both host parsers +# decode the escape happily and the refusal used to fall out of the +# memory boundary — as a crash on one host and a silent U+FFFD +# substitution on the other (#1308). +# +# Each refusal has ONE sentence, built here and hand-copied into +# ``vera/browser/runtime.mjs``; ``tests/test_browser.py`` holds the copy +# against this original so the two hosts cannot drift into saying +# different things about the same input. + + +def _non_finite_parse_message(name: str) -> str: + """The single sentence both runtimes return for a bare ``NaN``. + + ``name`` is the constant as it appears in the text — ``"NaN"``, + ``"Infinity"`` or ``"-Infinity"`` — which is what Python's + ``parse_constant`` hook is handed and what the browser's twin scan + finds. + """ + return ( + f"json_parse: {name} is not valid JSON — RFC 8259 has no NaN or " + f"Infinity. json_parse accepts RFC 8259 text only, not the " + f"JavaScript constants: quote the value as a string, or write null." + ) + + +def _lone_surrogate_message(code_point: int) -> str: + """The single sentence both runtimes return for a lone surrogate. + + The code point is rendered in the canonical ``\\uXXXX`` escape form + with uppercase hex, so the message does not depend on how the input + spelled its escape. + """ + return ( + f"json_parse: \\u{code_point:04X} decodes to a lone surrogate, which " + f"is not a Unicode scalar value — a Vera string is a sequence of " + f"scalar values, so this text has no representable decoding. Write " + f"the character as a matched high-then-low surrogate escape pair, or " + f"remove the escape." + ) + + +def _first_lone_surrogate_in_str(text: str) -> int | None: + """The first surrogate code point in ``text``, or ``None``. + + Every surrogate reaching this function is lone: ``json.loads`` + combines a well-formed ``\\uD83D\\uDE00`` escape pair into the single + astral code point it denotes, so anything left in D800–DFFF failed to + pair during decoding. A plain range test is therefore complete here. + + The browser's twin cannot be this simple. JS strings are UTF-16, so + a paired astral character is still *stored* as two surrogate code + units and the scan there has to consume pairs before judging what is + lone — same rule ("no code point outside the scalar values"), applied + to a different representation of the decoded value. + """ + for ch in text: + code_point = ord(ch) + if 0xD800 <= code_point <= 0xDFFF: + return code_point + return None + +def _non_finite_number_message(name: str) -> str: + """The single sentence both runtimes return for an overflowing number. + + The sibling of :func:`_non_finite_parse_message`: the same exclusion + — no accepted text decodes to a non-finite number — reached by a + different syntax. ``1e999`` breaks no RFC 8259 rule, so this one + cites the permission the refusal rests on rather than a prohibition. + """ + return ( + f"json_parse: a number in the text overflows to {name}, which JSON " + f"cannot represent — RFC 8259 §6 lets an implementation set limits " + f"on the range of numbers it accepts, and Vera's accepted range is " + f"the finite Float64 values. Keep the magnitude at or below " + f"1.7976931348623157e308, or carry the value as a string." + ) + + +# The smallest magnitude whose nearest double is an infinity. +# +# ``json.loads`` returns a Python ``int`` for a digit string with no +# fraction and no exponent, and an ``int`` of any size is finite — but it +# still has to become an f64 at the WASM boundary, where ``float()`` +# raises rather than saturating. So the integer arm needs its own range +# check, and the check has to be pure integer arithmetic: implementing it +# as ``float(value)`` would BE the overflow it is looking for. +# +# The bound is the double ROUNDING boundary, not ``sys.float_info.max``. +# The largest finite double is ``2**1024 - 2**971``; the next value the +# format could name is ``2**1024``; the midpoint between them is +# ``2**1024 - 2**970``, and ties-to-even sends that midpoint upward to +# the infinity. Everything strictly below rounds DOWN to the largest +# finite double and is perfectly representable — including integers +# larger than ``sys.float_info.max`` itself, which ``JSON.parse`` accepts +# and a bound of ``int(sys.float_info.max)`` would wrongly refuse here +# alone. ``TestIntegerOverflowRefusal1306`` pins the derivation against +# ``float()`` as its oracle, and the band between the two candidate +# bounds as a control. +_INT_ROUNDS_TO_INFINITY = 2**1024 - 2**970 + + +def first_domain_violation(value: Any) -> str | None: + """The ``Err`` message for the first out-of-domain value, or ``None``. + + One walk over the decoded tree for both value-level exclusions of + spec §9.7.1 — a string holding a lone surrogate, and a number that + is not finite. Both are properties of the decoded VALUE rather than + of the text, so both are found here rather than at the parse gate, + and finding them in one traversal is what makes "whichever comes + first names the refusal" the rule, instead of a precedence table the + two hosts could implement differently. + + Document order means, for an object, each key before its own value. + Keys are checked as well as values: a key crosses the WASM boundary + as a string exactly like a value does, and the key position is the + one #1308's own reproduction used. + + Returning the sentence rather than the offending code point or float + keeps the violation-to-message mapping in one place — a caller + cannot pair a found violation with the wrong message — and gives the + browser's twin the same kind of thing to return. + + Non-finite numbers reach the domain two ways. ``1e999`` is a + syntactically valid RFC 8259 number that overflows on decoding, and + is caught here. The bare constants ``NaN`` / ``Infinity`` are + refused earlier, at the parse gate, because there the *text* is not + RFC 8259 and each host's own parser decides that. One exclusion, + two entry routes, two sentences — the one that fires says which + route the text took. + + Both ``float`` and ``int`` are range-checked, and the ``int`` arm is + not redundant. ``json.loads`` yields an ``int`` for a digit string + with no fraction and no exponent, and it is true that a Python + ``int`` cannot be infinite however many digits it has — but that is + not the question. The value must still become an f64 at the WASM + boundary, and ``float()`` raises there for a magnitude past the + rounding boundary, so an int-shaped ``1`` followed by 309 zeros + reached ``write_json`` and killed the host where the browser — which + has no int/float split and sees an ``Infinity`` either way — had + returned the shared sentence all along. ``bool`` subclasses ``int`` + and is excluded explicitly: a JSON boolean is not a number. + """ + if isinstance(value, str): + code_point = _first_lone_surrogate_in_str(value) + if code_point is None: + return None + return _lone_surrogate_message(code_point) + if isinstance(value, float): + if value != value or value in (float("inf"), float("-inf")): + return _non_finite_number_message(_NON_FINITE_NAMES[repr(value)]) + return None + if isinstance(value, int) and not isinstance(value, bool): + # ``bool`` subclasses ``int``; a boolean is a JSON boolean and + # is never range-checked. The comparisons below stay in integer + # arithmetic all the way down, so a 400-digit literal is refused + # rather than raising on its way to being measured. + if value >= _INT_ROUNDS_TO_INFINITY: + return _non_finite_number_message("Infinity") + if value <= -_INT_ROUNDS_TO_INFINITY: + return _non_finite_number_message("-Infinity") + return None + if isinstance(value, list): + for item in value: + found = first_domain_violation(item) + if found is not None: + return found + return None + if isinstance(value, dict): + for key, item in value.items(): + if isinstance(key, str): + code_point = _first_lone_surrogate_in_str(key) + if code_point is not None: + return _lone_surrogate_message(code_point) + found = first_domain_violation(item) + if found is not None: + return found + return None + return None + + def write_json( caller: wasmtime.Caller, alloc: AllocFn, From 411fab8b97b4981a512075b21b7193f066d3f563 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 15:19:24 +0100 Subject: [PATCH 41/69] Share the accept-domain fixtures, and give the flag knobs one rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit wave 1 on PR #1319. Nine findings, none touching the domain semantics; the two structural ones each turned out to be hiding a second defect. The two accept-domain batteries had byte-duplicated the Vera probe program, the JSON-into-a-Vera-literal escaper, the `OK:` / `ERR:` protocol and the integer-overflow boundary constants. A reference-host battery and a cross-host one only mean the same thing if they send `json_parse` the same bytes, and the escaper's own docstring says one backslash changes which bytes those are — so two copies do not fail loudly when they drift, they leave one battery testing an input its case table does not name. All of it moves to `tests/json_domain_helpers.py`, following the `tests/codegen_helpers.py` pattern. Sharing is proved rather than asserted: one mutation of the shared escaper reddens both batteries, 22 cases and 20. `VERA_EAGER_GC` and `VERA_DEBUG_HOST_ERRORS` each parsed their own value, and unifying them found they had already drifted — the first accepted `on` and the second did not, and neither ENVIRONMENT.md section mentioned it. Both now read `vera.envflags.flag_enabled`, a leaf module importing only `os` so either end of the compiler can call it without a cycle. The shared set is their union: widening the narrower knob is safe, where narrowing the wider one would quietly stop honouring `VERA_EAGER_GC=on` for whoever is already typing it. The parity test measures the shared implementation, `off` included as a negative. The guarded region in `execute()` includes the executor teardown in its `finally`, so a teardown failure classifies as `host_error`. Moving it out was measured rather than assumed: keeping #841's run-on-every-path contract means attaching the `finally` to the handler's own `try`, which inverts the order — the handler would read `output_buf` before the pool is joined, losing whatever an in-flight worker writes during `shutdown(wait=True)`. It stays inside, and the comment now says so; `shutdown` does not surface worker exceptions, so a failure there is a real host-side failure and `host_error` is the right shape for it. Two cells pin the exceptions the conversion must NOT swallow. `SystemExit` and `KeyboardInterrupt` sit outside `except Exception` by design — one ends the process, the other maps Ctrl-C to exit 130 with the captured output intact. Both pass today; they exist for the edit that would break them, widening the handler to `except BaseException`, which reads as an improvement right up until Ctrl-C stops working. They raise from a real host callback on the ordinary route, where the existing Ctrl-C suite drives `IO.sleep` and its own interrupt path. The three accept-domain message helpers become public names — three importers and a documented surface — with no underscore aliases left. The knob's compile helper gets a concrete return type, dropping the `type: ignore`s that only existed because it erased one. Docs: the `VERA_DEBUG_HOST_ERRORS=1` prefix in TESTING.md was inert (the suite sets and unsets the variable itself); `host_error` is attributed to `_classify_host_error` on `execute()`'s non-`Trap` branch rather than to `_classify_trap`'s mapping; and vera/README.md's JavaScript total was summing a stale figure — recomputed from the files at 3,983, with the module-map row and the prose corrected to the measured 3,877 alongside. Co-Authored-By: Claude --- ENVIRONMENT.md | 4 +- TESTING.md | 11 +- tests/json_domain_helpers.py | 99 ++++++++++++ tests/test_browser.py | 101 +++++------- tests/test_json_accept_domain_1306_1308.py | 174 +++++++++------------ tests/test_runtime_traps.py | 117 ++++++++++++-- vera/README.md | 7 +- vera/browser/runtime.mjs | 4 +- vera/codegen/api.py | 26 ++- vera/codegen/assembly.py | 7 +- vera/envflags.py | 35 +++++ vera/runtime/json.py | 4 +- vera/wasm/json_serde.py | 24 +-- 13 files changed, 403 insertions(+), 210 deletions(-) create mode 100644 tests/json_domain_helpers.py create mode 100644 vera/envflags.py diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index 2f59cd5e..cff42048 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -69,7 +69,7 @@ CI sets this for the browser-parity job; local runs typically don't need it. Se ## `VERA_EAGER_GC` -A diagnostic knob for hunting GC-rooting bugs in the WASM codegen. Set to `1`, `true`, or `yes` at **compile time** to make the emitted `$alloc` function call `$gc_collect` on every allocation, regardless of memory pressure: +A diagnostic knob for hunting GC-rooting bugs in the WASM codegen. Set to `1`, `true`, `yes` or `on` (case-insensitive, surrounding whitespace ignored) at **compile time** to make the emitted `$alloc` function call `$gc_collect` on every allocation, regardless of memory pressure: ```bash VERA_EAGER_GC=1 vera run program.vera @@ -85,7 +85,7 @@ This was the diagnostic that cracked [#593](https://github.com/aallan/vera/issue ## `VERA_DEBUG_HOST_ERRORS` -A diagnostic knob for debugging the host bindings themselves. Set to `1`, `true`, or `yes` (case-insensitive, surrounding whitespace ignored — the same spellings [`VERA_EAGER_GC`](#vera_eager_gc) accepts) to make `execute()` re-raise a host callback's original Python exception instead of converting it to a `WasmTrapError`: +A diagnostic knob for debugging the host bindings themselves. Set to `1`, `true`, `yes` or `on` — the same spellings [`VERA_EAGER_GC`](#vera_eager_gc) accepts, because both read the one predicate in `vera/envflags.py` — to make `execute()` re-raise a host callback's original Python exception instead of converting it to a `WasmTrapError`: ```bash VERA_DEBUG_HOST_ERRORS=1 vera run program.vera diff --git a/TESTING.md b/TESTING.md index 35d42f3b..339922e6 100644 --- a/TESTING.md +++ b/TESTING.md @@ -35,8 +35,9 @@ VERA_JS_COVERAGE=1 pytest tests/test_browser.py -v # V8 coverage via c8 # GC-rooting diagnostic (forces $gc_collect on every alloc, see ENVIRONMENT.md) VERA_EAGER_GC=1 pytest tests/test_codegen_closures.py::TestClosureReturnShadowPushBalance -v -# Host-binding diagnostic (re-raises a host callback's own exception, see ENVIRONMENT.md) -VERA_DEBUG_HOST_ERRORS=1 pytest tests/test_runtime_traps.py::TestHostErrorDebugKnob1302 -v +# Host-binding diagnostic (re-raises a host callback's own exception, see ENVIRONMENT.md). +# The suite sets and unsets VERA_DEBUG_HOST_ERRORS itself, so run it without a prefix: +pytest tests/test_runtime_traps.py::TestHostErrorDebugKnob1302 -v # Type checking mypy vera/ # strict mode @@ -164,7 +165,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_codegen_io.py` | 42 | 821 | IO operations (#135: read\_line, read\_file, write\_file, args, exit, get\_env, sleep, time, stderr), Markdown + Regex host bindings (#419 split) | | `test_codegen_collections.py` | 69 | 1,141 | Map + Set collections (#62), wrapper-handle bit-31 tagging (#578) (#419 split) | | `test_codegen_json.py` | 116 | 1,112 | Json collection, typed accessors (#419 split), canonical serialization (#1293 — `format_json_number`'s ECMAScript boundaries and `dumps_canonical`'s shape, insertion-ordered keys, non-finite refusal, and rejection of values outside `read_json`'s domain) | -| `test_json_accept_domain_1306_1308.py` | 97 | 742 | `json_parse`'s accepted domain on the reference host (#1306, #1308): spec §9.7.1 states it as RFC 8259-valid text that decodes to finite numbers and strings of Unicode scalar values, and all three exclusions are pinned end-to-end through a compiled program that reports which arm it took plus the whole `Err` message — the JavaScript constants at the top level and nested in containers, with the first of two naming the refusal; a number that OVERFLOWS to an infinity (`1e999`, `-1e999`, `[1e999]`, `{"a":1e309}`, `1E999`, `[[1e999]]`), the second entry route to a non-finite `JNumber` and the one both host parsers accepted, so it diverged from the stated domain on both hosts at once rather than between them; the same overflow in its INTEGER spelling (`1` followed by 309, 310 and 400 zeros, signed and nested), which was reference-host-only because `json.loads` returns an `int` there and a float-only range check never saw it, with the bound pinned as the double ROUNDING boundary against `float()` as oracle and `int(sys.float_info.max) + 1` as the control that separates it from the obvious-but-wrong bound; and a lone surrogate parameterised over position (value, key, array element, nested, top-level string) and escape casing. The controls carry the weight the refusals cannot: matched surrogate pairs (single, adjacent, at end of string, literal astral) still parse, `1e308` / `-1e308` / the largest representable double still parse so the overflow refusal is a boundary and not a wall, **underflow is decided rather than assumed** (`1e-999` decodes to `0`, finite and in the domain, so it is accepted — the plausible wrong answer is symmetry with overflow), `"NaN"` as a string value and as a key is ordinary JSON, and text malformed for any other reason keeps its host-native syntax message — the constant-lookalike shapes (`[Infinity_x]`, `{Infinity:1}`) that a raise-on-sight `parse_constant` hook would have misreported, which is why the hook records and the refusal is decided after the parse, and the sign/case shapes (`-NaN`, `[-NaN]`, `+Infinity`, `infinity`, `nan`, `-Infinityx`) that a token scan without a value-start constraint would have claimed. Unit tests on `first_domain_violation` — the ONE document-order walk both value-level exclusions share, so "whichever comes first names the refusal" needs no precedence table — pin the traversal directly (key before its own value, earlier entry before later, D800/DFFF inclusive at both ends, an `int` and a `bool` never read as a non-finite number, and the NaN arm that no JSON text can reach) where the end-to-end probe can only observe the first refusal. The cross-host half is `TestBrowserJsonAcceptDomainParity1306_1308` in `test_browser.py` | +| `test_json_accept_domain_1306_1308.py` | 97 | 718 | `json_parse`'s accepted domain on the reference host (#1306, #1308): spec §9.7.1 states it as RFC 8259-valid text that decodes to finite numbers and strings of Unicode scalar values, and all three exclusions are pinned end-to-end through a compiled program that reports which arm it took plus the whole `Err` message — the JavaScript constants at the top level and nested in containers, with the first of two naming the refusal; a number that OVERFLOWS to an infinity (`1e999`, `-1e999`, `[1e999]`, `{"a":1e309}`, `1E999`, `[[1e999]]`), the second entry route to a non-finite `JNumber` and the one both host parsers accepted, so it diverged from the stated domain on both hosts at once rather than between them; the same overflow in its INTEGER spelling (`1` followed by 309, 310 and 400 zeros, signed and nested), which was reference-host-only because `json.loads` returns an `int` there and a float-only range check never saw it, with the bound pinned as the double ROUNDING boundary against `float()` as oracle and `int(sys.float_info.max) + 1` as the control that separates it from the obvious-but-wrong bound; and a lone surrogate parameterised over position (value, key, array element, nested, top-level string) and escape casing. The controls carry the weight the refusals cannot: matched surrogate pairs (single, adjacent, at end of string, literal astral) still parse, `1e308` / `-1e308` / the largest representable double still parse so the overflow refusal is a boundary and not a wall, **underflow is decided rather than assumed** (`1e-999` decodes to `0`, finite and in the domain, so it is accepted — the plausible wrong answer is symmetry with overflow), `"NaN"` as a string value and as a key is ordinary JSON, and text malformed for any other reason keeps its host-native syntax message — the constant-lookalike shapes (`[Infinity_x]`, `{Infinity:1}`) that a raise-on-sight `parse_constant` hook would have misreported, which is why the hook records and the refusal is decided after the parse, and the sign/case shapes (`-NaN`, `[-NaN]`, `+Infinity`, `infinity`, `nan`, `-Infinityx`) that a token scan without a value-start constraint would have claimed. Unit tests on `first_domain_violation` — the ONE document-order walk both value-level exclusions share, so "whichever comes first names the refusal" needs no precedence table — pin the traversal directly (key before its own value, earlier entry before later, D800/DFFF inclusive at both ends, an `int` and a `bool` never read as a non-finite number, and the NaN arm that no JSON text can reach) where the end-to-end probe can only observe the first refusal. The cross-host half is `TestBrowserJsonAcceptDomainParity1306_1308` in `test_browser.py` | | `test_codegen_decimal.py` | 57 | 779 | Decimal collection, Decimal monomorphization (#419 split) | | `test_codegen_host_effects.py` | 71 | 1,123 | Html/Http/Inference host effects, provider dispatch, postcondition host-import propagation (#823) (#419 split) | | `test_codegen_nat_guards.py` | 61 | 1,435 | **`@Nat` runtime guards**: subtraction underflow (#520) and binding-site narrowing (#552 let site; #747 tuple-destructure / match-bind / ADT sub-pattern / ctor-field / call-arg sites; #758 per-leaf function-return guards incl. type-alias returns and TCO preservation on mixed-arm tails — `i64.lt_s; unreachable` net, `@Int` targets exempt) (#419 split); #758 `@Int -> @Nat` return-position guard; #983 review adds alias-aware return gates (`type Count = Nat` narrow, `type MyInt = Int` widen), the alias-to-refinement single-guard exclusion, and the per-narrowing-leaf emission that keeps a non-narrowing `@Nat -> @Nat` recursive tail call's `return_call` (TCO) intact. #1256 extends both alias-aware gates to a parameterised alias APPLICATION (`type Ident = T; type Count = Ident;`), which the name-only chase resolved to the bare head `Ident` — so neither gate fired and `f(0 - 5)` returned -5 through the `@Nat` slot, #983's silent negative one spelling over. Each parameterised case carries its unparameterised twin as the oracle (the claim is that the two spellings compile to the SAME guard, which a bare presence assertion would not catch losing), plus the run-trap on the violating value, the pass on a satisfying one, and the refinement-over-application control that pins the `_refinement_guard_parts` conjunct still keeps a refined return single-guarded | @@ -226,7 +227,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_check_limitations_sync.py` | 6 | 108 | `check_limitations_sync.py` section extraction: table-rows-only issue harvesting, prose-link exemption, bounding at the next second-level heading, `None` for absent or sub-level headings so renamed sections fail loudly; plus the #852 fail-loud rule: an UNKNOWN issue state under `--check-states` (gh missing / auth failure / timeout) is an error, never a silent pass | | `test_doc_annotations.py` | 23 | 340 | `scripts/doc_annotations.py` — the inline `vera:skip-` fence-annotation reader and shared `run_parse_only_gate` used by the doc-block gates ([#538](https://github.com/aallan/vera/issues/538)): markdown/HTML scanning (annotation attached to the following fence / `
`, stacked directives), hard problems (malformed, dangling incl. EOF, duplicate-stage, unknown-stage, unterminated fence / unclosed `
`; prose mentions without comment syntax are fine), the gate round-trip semantics via `evaluate_block` (unannotated failure fails, annotated failure skips, annotated PASS is a stale annotation, skip-check still runs parse first and stops the pipeline), unsupported-stage detection for parse-only gates, and `strip_annotations` (annotation lines removed, other HTML comments survive) |
 | `test_doc_builtin_shadowing.py` | 8 | 107 | `check_doc_builtin_shadowing.py` gate ([#819](https://github.com/aallan/vera/issues/819)): reject-set membership (opaque built-ins in, overridable combinators out), top-level + `where`-block `fn ` definitions flagged, non-built-in / overridable / prose-mention ignored, and the shipped docs are currently clean |
-| `test_runtime_traps.py` | 93 | 3,156 | Runtime trap categorisation (#516 Stage 1), out-of-bounds host-read bounds check (#1145), stdout/stderr-on-trap preservation (#522), `IO.print` live tee (#543), and trap source backtrace (#516 Stage 2): `_classify_trap` per-`kind` mapping (`divide_by_zero`/`out_of_bounds`/`stack_exhausted`/`unreachable`/`overflow`/`contract_violation`/`host_error`/`unknown`), `WasmTrapError` shape + `RuntimeError` substitutability, end-to-end `cmd_run` text + JSON envelopes including `trap_kind`, captured `stdout`, captured `stderr`, JSON-mode "no stderr leak" invariant, cross-stream code-order regression using merged `redirect_stdout`/`redirect_stderr`, the v0.0.123 tee suite (live streaming, write-count + order preservation, JSON-mode tee suppression, trap preservation invariant under tee, per-write flush count, default-execute silence), and the v0.0.124 source-mapping suite — `_resolve_trap_frames` unit tests covering user-fn / built-in / built-in-prefix / monomorphized base-name fallback / unknown-name / no-frames-attribute / leaf-first ordering preservation; end-to-end `cmd_run` text-mode + JSON-mode backtrace including the **leaf-first** ordering invariant; contract-violation backtrace in both text and JSON modes; direct `execute()` `WasmTrapError.frames` attachment; **suppression marker** for collapsed leading runtime-helper frames (mocked `vera.codegen.execute` with synthetic `is_builtin=True` leaf frames so the collapse logic is testable deterministically); source-map population for top-level fns + lifted closures (with span-value assertion against the closure literal's exact line range); and the no-builtin-leakage regression that pins built-in helpers (`alloc` / `gc_collect` / `contract_fail`) NOT being registered in `fn_source_map`; plus the v0.0.125 Stage 3 suite (`#547`) — text-mode `Fix:` block surfacing with position-ordering invariant (Fix appears after the source backtrace), text-mode block suppression for `contract_violation` (no empty header noise), JSON-mode `fix` field always-present (schema stability) including the empty-string case, `_TRAP_FIX_PARAGRAPHS` table-completeness assertion (every kind in the taxonomy has a Fix paragraph entry), and the column-wrap invariant (~76 chars max per line, two-space indent under the `Fix:` heading); plus the UTF-8 hardening suite **`TestHostPrintInvalidUtf8589`** (`#589` / `#592`) — after `#592` centralised the `errors="replace"` invariant into the single `vera.runtime.text.safe_utf8_decode` helper — reached only through a shared `_slice_and_decode` helper (`vera/runtime/heap.py`) that the three WASM-memory string readers (`_read_wasm_string` and `_read_string_export` there, and `vera/wasm/markdown.py::_read_string`) delegate to, with the `host_print` / `host_stderr` / `host_contract_fail` host imports and the String-return extractor in `execute()` routing through those readers rather than decoding inline: one helper unit test pinning the invariant once (invalid bytes → U+FFFD, valid + empty pass through), three wire-real end-to-end tests that drive the **production** readers (`_read_wasm_string` / markdown `_read_string` behind a synthetic-WAT `probe` host import; `_read_string_export` against a real exported memory, also covering its out-of-bounds → `None` pointer-fallback) over a region seeded with invalid UTF-8 — so a strict-decode regression surfaces as a `UnicodeDecodeError` escaping wasmtime's trampoline, and the host imports / extractor are transitively covered — and one synthetic-WAT end-to-end test that imports `vera.print` and calls it with raw invalid UTF-8 bytes to pin the wasmtime-trampoline fact independently (a Python `UnicodeDecodeError` inside a host import escapes as a "python exception" cause iff the host decode is strict); the six pre-`#592` structural source-grep assertions were retired by the centralisation; plus the Ctrl-C-during-host-import suite **`TestHostSleepKeyboardInterrupt`** ([#595](https://github.com/aallan/vera/issues/595) / [#599](https://github.com/aallan/vera/issues/599)) — after the v0.0.160 relocation to a single `except KeyboardInterrupt` handler in `execute()` (enabled by `wasmtime>=45.0.0`'s `except BaseException` trampoline fix): one structural assertion that the four per-host-import `raise _VeraExit(130)` guards are gone and the centralized handler maps to `exit_code=130`, plus four end-to-end tests that compile real Vera programs calling `IO.sleep(...)`, `IO.read_char(())`, a mocked fused `await` ([#841](https://github.com/aallan/vera/issues/841) — `Future.result()` patched to interrupt), and a live in-flight fused `await` (no mocking — `_thread.interrupt_main()` fired only once the server confirms the request arrived, handler then released so the executor teardown has a real worker to wait out; post-[#848](https://github.com/aallan/vera/issues/848) the progress print precedes the `async(...)`, so program order makes its stdout assertion deterministic), raise `KeyboardInterrupt` from inside the blocking call, and assert the program exits with `ExecuteResult.exit_code == 130` (pre-interrupt stdout preserved) instead of a raw Python traceback escaping wasmtime's trampoline; plus the host-callback surface suite **`TestHostCallbackErrorSurface1302`** / **`TestClassifyHostError1302`** ([#1302](https://github.com/aallan/vera/issues/1302)) — `execute()` classified on the exception's TYPE NAME (`Trap` / `WasmtimeError`), so a host import raising an ordinary Python exception skipped the conversion and escaped as a 63-line interpreter traceback with the captured streams dropped, and in `--json` mode with no envelope emitted at all.  The conversion is now keyed on the BOUNDARY (everything escaping the guest invocation), and the suite drives a real `json_stringify(JNumber(nan()))` program through all three surfaces: `execute()` raising a `WasmTrapError` of `kind="host_error"` carrying the host's sentence, the pre-failure stdout and the original exception as `__cause__`; text-mode `cmd_run` asserted on the ABSENCE of `Traceback` / `File "` / `wasmtime` and a sub-ten-line diagnostic, since asserting only that the sentence appears would still pass on the pre-fix output where it was the traceback's last line; and JSON-mode `cmd_run` producing a parseable envelope with `trap_kind`, an always-present empty `fix`, `frames`, and the captured `stdout`.  **`TestHostErrorDebugKnob1302`** covers the escape hatch the conversion needs — `VERA_DEBUG_HOST_ERRORS` (ENVIRONMENT.md) re-raises the original exception so a binding bug stays diagnosable — as a deliberate pair, one test proving the knob does something and one proving its absence is what produces the one-liner, since neither alone distinguishes a working knob from unconditional behaviour, plus the truthiness table shared with `VERA_EAGER_GC` and an end-to-end `cmd_run` case |
+| `test_runtime_traps.py` | 98 | 3,255 | Runtime trap categorisation (#516 Stage 1), out-of-bounds host-read bounds check (#1145), stdout/stderr-on-trap preservation (#522), `IO.print` live tee (#543), and trap source backtrace (#516 Stage 2): `_classify_trap` per-`kind` mapping (`divide_by_zero`/`out_of_bounds`/`stack_exhausted`/`unreachable`/`overflow`/`contract_violation`/`unknown`), plus `host_error` from `_classify_host_error` on `execute()`'s non-`Trap` branch, `WasmTrapError` shape + `RuntimeError` substitutability, end-to-end `cmd_run` text + JSON envelopes including `trap_kind`, captured `stdout`, captured `stderr`, JSON-mode "no stderr leak" invariant, cross-stream code-order regression using merged `redirect_stdout`/`redirect_stderr`, the v0.0.123 tee suite (live streaming, write-count + order preservation, JSON-mode tee suppression, trap preservation invariant under tee, per-write flush count, default-execute silence), and the v0.0.124 source-mapping suite — `_resolve_trap_frames` unit tests covering user-fn / built-in / built-in-prefix / monomorphized base-name fallback / unknown-name / no-frames-attribute / leaf-first ordering preservation; end-to-end `cmd_run` text-mode + JSON-mode backtrace including the **leaf-first** ordering invariant; contract-violation backtrace in both text and JSON modes; direct `execute()` `WasmTrapError.frames` attachment; **suppression marker** for collapsed leading runtime-helper frames (mocked `vera.codegen.execute` with synthetic `is_builtin=True` leaf frames so the collapse logic is testable deterministically); source-map population for top-level fns + lifted closures (with span-value assertion against the closure literal's exact line range); and the no-builtin-leakage regression that pins built-in helpers (`alloc` / `gc_collect` / `contract_fail`) NOT being registered in `fn_source_map`; plus the v0.0.125 Stage 3 suite (`#547`) — text-mode `Fix:` block surfacing with position-ordering invariant (Fix appears after the source backtrace), text-mode block suppression for `contract_violation` (no empty header noise), JSON-mode `fix` field always-present (schema stability) including the empty-string case, `_TRAP_FIX_PARAGRAPHS` table-completeness assertion (every kind in the taxonomy has a Fix paragraph entry), and the column-wrap invariant (~76 chars max per line, two-space indent under the `Fix:` heading); plus the UTF-8 hardening suite **`TestHostPrintInvalidUtf8589`** (`#589` / `#592`) — after `#592` centralised the `errors="replace"` invariant into the single `vera.runtime.text.safe_utf8_decode` helper — reached only through a shared `_slice_and_decode` helper (`vera/runtime/heap.py`) that the three WASM-memory string readers (`_read_wasm_string` and `_read_string_export` there, and `vera/wasm/markdown.py::_read_string`) delegate to, with the `host_print` / `host_stderr` / `host_contract_fail` host imports and the String-return extractor in `execute()` routing through those readers rather than decoding inline: one helper unit test pinning the invariant once (invalid bytes → U+FFFD, valid + empty pass through), three wire-real end-to-end tests that drive the **production** readers (`_read_wasm_string` / markdown `_read_string` behind a synthetic-WAT `probe` host import; `_read_string_export` against a real exported memory, also covering its out-of-bounds → `None` pointer-fallback) over a region seeded with invalid UTF-8 — so a strict-decode regression surfaces as a `UnicodeDecodeError` escaping wasmtime's trampoline, and the host imports / extractor are transitively covered — and one synthetic-WAT end-to-end test that imports `vera.print` and calls it with raw invalid UTF-8 bytes to pin the wasmtime-trampoline fact independently (a Python `UnicodeDecodeError` inside a host import escapes as a "python exception" cause iff the host decode is strict); the six pre-`#592` structural source-grep assertions were retired by the centralisation; plus the Ctrl-C-during-host-import suite **`TestHostSleepKeyboardInterrupt`** ([#595](https://github.com/aallan/vera/issues/595) / [#599](https://github.com/aallan/vera/issues/599)) — after the v0.0.160 relocation to a single `except KeyboardInterrupt` handler in `execute()` (enabled by `wasmtime>=45.0.0`'s `except BaseException` trampoline fix): one structural assertion that the four per-host-import `raise _VeraExit(130)` guards are gone and the centralized handler maps to `exit_code=130`, plus four end-to-end tests that compile real Vera programs calling `IO.sleep(...)`, `IO.read_char(())`, a mocked fused `await` ([#841](https://github.com/aallan/vera/issues/841) — `Future.result()` patched to interrupt), and a live in-flight fused `await` (no mocking — `_thread.interrupt_main()` fired only once the server confirms the request arrived, handler then released so the executor teardown has a real worker to wait out; post-[#848](https://github.com/aallan/vera/issues/848) the progress print precedes the `async(...)`, so program order makes its stdout assertion deterministic), raise `KeyboardInterrupt` from inside the blocking call, and assert the program exits with `ExecuteResult.exit_code == 130` (pre-interrupt stdout preserved) instead of a raw Python traceback escaping wasmtime's trampoline; plus the host-callback surface suite **`TestHostCallbackErrorSurface1302`** / **`TestClassifyHostError1302`** ([#1302](https://github.com/aallan/vera/issues/1302)) — `execute()` classified on the exception's TYPE NAME (`Trap` / `WasmtimeError`), so a host import raising an ordinary Python exception skipped the conversion and escaped as a 63-line interpreter traceback with the captured streams dropped, and in `--json` mode with no envelope emitted at all.  The conversion is now keyed on the BOUNDARY (everything escaping the guest invocation), and the suite drives a real `json_stringify(JNumber(nan()))` program through all three surfaces: `execute()` raising a `WasmTrapError` of `kind="host_error"` carrying the host's sentence, the pre-failure stdout and the original exception as `__cause__`; text-mode `cmd_run` asserted on the ABSENCE of `Traceback` / `File "` / `wasmtime` and a sub-ten-line diagnostic, since asserting only that the sentence appears would still pass on the pre-fix output where it was the traceback's last line; and JSON-mode `cmd_run` producing a parseable envelope with `trap_kind`, an always-present empty `fix`, `frames`, and the captured `stdout`.  **`TestHostErrorDebugKnob1302`** covers the escape hatch the conversion needs — `VERA_DEBUG_HOST_ERRORS` (ENVIRONMENT.md) re-raises the original exception so a binding bug stays diagnosable — as a deliberate pair, one test proving the knob does something and one proving its absence is what produces the one-liner, since neither alone distinguishes a working knob from unconditional behaviour, plus the truthiness table shared with `VERA_EAGER_GC` and an end-to-end `cmd_run` case |
 | `test_serve.py` | 8 | 189 | #305 `vera serve` driver end-to-end: GET/POST echo round-trips (method/path/headers/body cross the host↔guest boundary via `build_request_adt` / `decode_response_adt`), handler status propagation, runtime contract violation → 500 with `trap_kind` JSON, `State` isolation across requests (instance-per-request pinned), and clean `make_server` validation errors (missing / wrong-signature `handle`), and an eager-GC round-trip pinning the Request builder's shadow-rooting; all on ephemeral ports |
 | `test_wasi_target.py` | 270 | 2,142 | #237 WASI Preview 2 target (spec chapter 13): component emission validated live against the real wasmtime host — parse (`Component(engine, wat)`), instantiate (`Linker.add_wasip2()` + `WasiConfig`), and execute (stdout/stderr capture, env, argv incl. a 500-arg GC-pressure stress and a >64 KiB arena-cap trap, preopen file round-trips + errno mapping, stdin incl. UTF-8 multibyte, clocks, random bounds, exit, contract-violation text on WASI stderr, overflow); the family gate (clean diagnostic naming unsupported families, never a silent fallback); the core-emission pin (default `--target wasm` WAT untouched); `cmd_compile`/`cmd_run --target wasi-p2` CLI integration (binary component artifact, `--wat` component text, JSON envelopes, trap-kind classification through the component boundary, exit-code 0/1 degradation, `--fn` rejection); the `execute_wasi_p2` host runner (env passthrough, argv, stderr capture, String-main `wasi:cli/run` fallback); the **dual-target conformance differential** (all 169 run-level conformance programs driven under both targets, byte-identical stdout/stderr required — 119 are dual-tested and 50 skip *loudly* rather than passing silently: 43 whose compiled WAT imports a host family outside `IO`/`Random` (`state`, `map`, `json`, `set`, `decimal`, `html`, `md`, `regex`, `db`), 6 with no public zero-argument `main`, and 1 calling a nondeterministic op.  The excluded set is defined by those three properties rather than by a filename list, so it stays accurate as programs are added); a stock-`wasmtime`-CLI smoke test (skips when the CLI is not installed); and the Stage-D **server world** (`world="server"`): incoming-handler emission pins (adapter lift, 32-slot dispatch table, @0.2.0 version pin, no `wasi:cli/run`), #305 handler validation + server family-gate diagnostics (rejected IO ops, non-String map instantiations, unsupported families), the cli-world pin (default emission carries no server machinery), Request/Response layout tripwires, and a stock-`wasmtime serve` smoke battery (host-vs-served differential over a method/path/header/body matrix incl. duplicate-header later-wins, in-guest map-op order parity, `IO.print` console routing, trap→500 with symbolized backtrace + violation text, graceful 500s for forbidden headers and out-of-range status, a 1 MiB GC-stress echo, and an eager-GC shadow-push mutation validation; skips when the CLI is not installed) |
 
@@ -562,7 +563,7 @@ three split suites are the exception: the `test_checker_*.py` files (split from
 `test_checker.py`, #420) import their shared helpers from
 `tests/checker_helpers.py`; the `test_codegen_*.py` feature files (split from
 `test_codegen.py`, #419) import theirs — plus the `_IO_PRELUDE` /
-`_INLINE_BUILTIN_NAMES` fixture constants — from `tests/codegen_helpers.py`;
+`_INLINE_BUILTIN_NAMES` fixture constants — from `tests/codegen_helpers.py`; the two `json_parse` accept-domain batteries (#1306 / #1308) share their Vera probe program, its JSON-into-a-Vera-literal escaper, the `OK:` / `ERR:` output protocol and the two integer-overflow boundary constants through `tests/json_domain_helpers.py`, because a reference-host battery and a cross-host one only mean the same thing if they send `json_parse` the same bytes — one mutation of that escaper reddens both, which is the property a second copy would quietly lose;
 and the `test_verifier_*.py` theme files (split from `test_verifier.py`, #839)
 import theirs — plus the `EXAMPLES_DIR` / `ALL_EXAMPLES` corpus constants and
 the `_MK` source template — from `tests/verifier_helpers.py`.
diff --git a/tests/json_domain_helpers.py b/tests/json_domain_helpers.py
new file mode 100644
index 00000000..c0abb471
--- /dev/null
+++ b/tests/json_domain_helpers.py
@@ -0,0 +1,99 @@
+"""Shared fixtures for the `json_parse` accept-domain batteries (#1306, #1308).
+
+Two batteries observe the same property from different sides —
+`tests/test_json_accept_domain_1306_1308.py` runs the reference host
+alone, `tests/test_browser.py::TestBrowserJsonAcceptDomainParity1306_1308`
+runs the identical `.wasm` under both runtimes — and they only mean the
+same thing if they send `json_parse` the same bytes and read its answer
+the same way.
+
+Everything that decides those two things lives here, following the
+`tests/codegen_helpers.py` pattern (a plain module, imported by name):
+
+    vera_lit(raw)               escape a JSON document for a Vera literal
+    accept_domain_src(raw_json) the probe program both batteries compile
+    ok(text) / err(message)     the probe's output protocol
+    INT_ROUNDS_TO_INFINITY      the integer overflow bound
+    MAX_FINITE_AS_INT           the bound it must NOT be confused with
+
+The escaper is the reason this module exists rather than two tidy
+copies.  One backslash either way changes which bytes `json_parse`
+receives — a surrogate escape and a literal backslash-u sequence differ
+by exactly that — so two implementations that drift apart do not fail
+loudly, they quietly leave one battery testing a different input than
+its case table names.
+"""
+
+from __future__ import annotations
+
+import sys
+
+# The smallest magnitude whose nearest double is an infinity, and so the
+# bound for a JSON integer literal.  Mirrors
+# ``vera.wasm.json_serde._INT_ROUNDS_TO_INFINITY``; the batteries pin the
+# derivation against ``float()`` as oracle rather than trusting either
+# copy.
+INT_ROUNDS_TO_INFINITY = 2**1024 - 2**970
+
+# The largest finite double as an exact integer.  Strictly smaller than
+# the bound above: integers between the two round DOWN to it and are
+# accepted, which is what makes this the wrong bound and a necessary
+# control.
+MAX_FINITE_AS_INT = int(sys.float_info.max)
+
+# The probe's output protocol.  Which arm `json_parse` took is reported
+# as a prefix so one byte-identical stdout comparison covers the arm AND
+# the message.
+OK_PREFIX = "OK:"
+ERR_PREFIX = "ERR:"
+
+
+def ok(canonical_text: str) -> str:
+    """The expected probe output for an accepted document."""
+    return OK_PREFIX + canonical_text
+
+
+def err(message: str) -> str:
+    """The expected probe output for a refused document."""
+    return ERR_PREFIX + message
+
+
+def vera_lit(raw: str) -> str:
+    """Escape ``raw`` for embedding in a Vera string literal.
+
+    The batteries' inputs are JSON documents full of quotes and
+    backslashes, and one backslash either way changes which bytes
+    ``json_parse`` receives.  Converting once, here, is what keeps every
+    call site honest about its own input.
+    """
+    return raw.replace("\\", "\\\\").replace('"', '\\"')
+
+
+_PROBE_TEMPLATE = """
+private fn probe(@String -> @String)
+  requires(true) ensures(true) effects(pure)
+{{
+  match json_parse(@String.0) {{
+    Ok(@Json) -> string_concat("{ok}", json_stringify(@Json.0)),
+    Err(@String) -> string_concat("{err}", @String.0)
+  }}
+}}
+
+public fn main(@Unit -> @Unit)
+  requires(true) ensures(true) effects()
+{{
+  IO.print(probe("{text}"))
+}}
+"""
+
+
+def accept_domain_src(raw_json: str) -> str:
+    """A ``main`` that reports which arm ``json_parse(raw_json)`` took.
+
+    Prints ``OK:`` or ``ERR:``.  Both batteries
+    compile this same program, so a difference between them can only be
+    the host, never the program.
+    """
+    return _PROBE_TEMPLATE.format(
+        ok=OK_PREFIX, err=ERR_PREFIX, text=vera_lit(raw_json),
+    )
diff --git a/tests/test_browser.py b/tests/test_browser.py
index 840b38ff..f160e75b 100644
--- a/tests/test_browser.py
+++ b/tests/test_browser.py
@@ -20,7 +20,6 @@
 import shutil
 import struct
 import subprocess
-import sys
 from pathlib import Path
 from typing import Any
 
@@ -32,11 +31,19 @@
 from vera.parser import parse_file
 from vera.resolver import ModuleResolver
 from vera.transform import transform
+from tests.json_domain_helpers import (
+    ERR_PREFIX,
+    INT_ROUNDS_TO_INFINITY,
+    MAX_FINITE_AS_INT,
+    accept_domain_src,
+    err,
+    ok,
+)
 from vera.wasm.json_serde import (
-    _lone_surrogate_message,
+    lone_surrogate_message,
     _non_finite_message,
-    _non_finite_number_message,
-    _non_finite_parse_message,
+    non_finite_number_message,
+    non_finite_parse_message,
     format_json_number,
 )
 
@@ -3925,38 +3932,6 @@ def test_non_finite_fails_on_both_hosts(
         assert expected in browser, browser
 
 
-def _vera_lit(raw: str) -> str:
-    """Escape ``raw`` for embedding in a Vera string literal.
-
-    The accept-domain battery's inputs are JSON documents full of quotes
-    and backslashes, and one backslash either way changes which bytes
-    ``json_parse`` receives — a surrogate escape and a literal
-    backslash-u sequence differ by exactly that.  Converting once at the
-    boundary is what keeps fifteen call sites honest about their input.
-    """
-    return raw.replace("\\", "\\\\").replace('"', '\\"')
-
-
-def _accept_domain_src(raw_json: str) -> str:
-    """A ``main`` that reports which arm ``json_parse(raw_json)`` took.
-
-    Prints ``OK:`` or ``ERR:``, so one
-    byte-identical stdout across the two runtimes covers the arm AND the
-    message in a single assertion.  The reference-host twin of this
-    probe is in ``tests/test_json_accept_domain_1306_1308.py``.
-    """
-    return f"""
-public fn main(@Unit -> @Unit)
-  requires(true) ensures(true) effects()
-{{
-  match json_parse("{_vera_lit(raw_json)}") {{
-    Ok(@Json) -> IO.print(string_concat("OK:", json_stringify(@Json.0))),
-    Err(@String) -> IO.print(string_concat("ERR:", @String.0))
-  }}
-}}
-"""
-
-
 # The four probe inputs from #1306's table, plus the container and
 # multi-constant shapes that pin how far the refusal reaches and which
 # constant names it.
@@ -4054,8 +4029,6 @@ def _accept_domain_src(raw_json: str) -> str:
 # such split and produced an ``Infinity`` here all along.  The bound is
 # the double ROUNDING boundary — an integer above ``sys.float_info.max``
 # but below the midpoint to 2**1024 rounds down and is accepted by both.
-_INT_ROUNDS_TO_INFINITY = 2**1024 - 2**970
-_MAX_FINITE_AS_INT = int(sys.float_info.max)
 
 _JSON_INT_OVERFLOW_CASES = [
     ("digits_309", "1" + "0" * 309, "Infinity"),
@@ -4063,15 +4036,15 @@ def _accept_domain_src(raw_json: str) -> str:
     ("negative_309", "-1" + "0" * 309, "-Infinity"),
     ("in_array", "[1" + "0" * 309 + "]", "Infinity"),
     ("in_object", '{"a":1' + "0" * 309 + "}", "Infinity"),
-    ("exact_rounding_boundary", str(_INT_ROUNDS_TO_INFINITY), "Infinity"),
+    ("exact_rounding_boundary", str(INT_ROUNDS_TO_INFINITY), "Infinity"),
 ]
 
 _JSON_INT_ACCEPTED_CASES = [
     ("digits_308", "1" + "0" * 308, "1e+308"),
     ("negative_digits_308", "-1" + "0" * 308, "-1e+308"),
-    ("boundary_minus_one", str(_INT_ROUNDS_TO_INFINITY - 1),
+    ("boundary_minus_one", str(INT_ROUNDS_TO_INFINITY - 1),
      "1.7976931348623157e+308"),
-    ("max_finite_as_int_plus_one", str(_MAX_FINITE_AS_INT + 1),
+    ("max_finite_as_int_plus_one", str(MAX_FINITE_AS_INT + 1),
      "1.7976931348623157e+308"),
     ("ordinary_integer", "42", "42"),
 ]
@@ -4118,9 +4091,9 @@ def test_non_finite_constants_refused_identically(
         self, case_id: str, raw_json: str, name: str, tmp_path: Path,
     ) -> None:
         out = _parity_stdout(
-            _accept_domain_src(raw_json), tmp_path, f"jsonnfp_{case_id}",
+            accept_domain_src(raw_json), tmp_path, f"jsonnfp_{case_id}",
         )
-        assert out == "ERR:" + _non_finite_parse_message(name)
+        assert out == err(non_finite_parse_message(name))
 
     @pytest.mark.parametrize(
         ("case_id", "raw_json", "code_point"),
@@ -4131,9 +4104,9 @@ def test_lone_surrogates_refused_identically(
         self, case_id: str, raw_json: str, code_point: int, tmp_path: Path,
     ) -> None:
         out = _parity_stdout(
-            _accept_domain_src(raw_json), tmp_path, f"jsonls_{case_id}",
+            accept_domain_src(raw_json), tmp_path, f"jsonls_{case_id}",
         )
-        assert out == "ERR:" + _lone_surrogate_message(code_point)
+        assert out == err(lone_surrogate_message(code_point))
 
     @pytest.mark.parametrize(
         ("case_id", "raw_json", "expected"),
@@ -4152,9 +4125,9 @@ def test_accepted_documents_are_unchanged(
         the refusals fire.
         """
         out = _parity_stdout(
-            _accept_domain_src(raw_json), tmp_path, f"jsonok_{case_id}",
+            accept_domain_src(raw_json), tmp_path, f"jsonok_{case_id}",
         )
-        assert out == "OK:" + expected
+        assert out == ok(expected)
 
     @pytest.mark.parametrize(
         ("case_id", "raw_json", "name"),
@@ -4173,9 +4146,9 @@ def test_overflow_to_infinity_refused_identically(
         program died at ``json_stringify`` instead.
         """
         out = _parity_stdout(
-            _accept_domain_src(raw_json), tmp_path, f"jsonovf_{case_id}",
+            accept_domain_src(raw_json), tmp_path, f"jsonovf_{case_id}",
         )
-        assert out == "ERR:" + _non_finite_number_message(name)
+        assert out == err(non_finite_number_message(name))
 
     @pytest.mark.parametrize(
         ("case_id", "raw_json", "expected"),
@@ -4194,9 +4167,9 @@ def test_finite_numbers_at_the_boundary_are_unchanged(
         would take it.
         """
         out = _parity_stdout(
-            _accept_domain_src(raw_json), tmp_path, f"jsonfin_{case_id}",
+            accept_domain_src(raw_json), tmp_path, f"jsonfin_{case_id}",
         )
-        assert out == "OK:" + expected
+        assert out == ok(expected)
 
     @pytest.mark.parametrize(
         ("case_id", "raw_json", "name"),
@@ -4217,9 +4190,9 @@ def test_integer_overflow_refused_identically(
         sentence rather than dying with a CPython one.
         """
         out = _parity_stdout(
-            _accept_domain_src(raw_json), tmp_path, f"jsonint_{case_id}",
+            accept_domain_src(raw_json), tmp_path, f"jsonint_{case_id}",
         )
-        assert out == "ERR:" + _non_finite_number_message(name)
+        assert out == err(non_finite_number_message(name))
 
     @pytest.mark.parametrize(
         ("case_id", "raw_json", "expected"),
@@ -4238,9 +4211,9 @@ def test_integers_that_round_into_range_are_unchanged(
         battery whose only large case is a round number of zeros.
         """
         out = _parity_stdout(
-            _accept_domain_src(raw_json), tmp_path, f"jsonintok_{case_id}",
+            accept_domain_src(raw_json), tmp_path, f"jsonintok_{case_id}",
         )
-        assert out == "OK:" + expected
+        assert out == ok(expected)
 
     @pytest.mark.parametrize(
         ("case_id", "raw_json"),
@@ -4262,10 +4235,10 @@ def test_malformed_text_keeps_its_host_native_message(
         ``-0`` and report a refusal the reference host never makes.
         """
         native, browser = _both_stdouts(
-            _accept_domain_src(raw_json), tmp_path, f"jsonsyn_{case_id}",
+            accept_domain_src(raw_json), tmp_path, f"jsonsyn_{case_id}",
         )
-        assert native.startswith("ERR:")
-        assert browser.startswith("ERR:")
+        assert native.startswith(ERR_PREFIX)
+        assert browser.startswith(ERR_PREFIX)
         assert "json_parse:" not in native, native
         assert "json_parse:" not in browser, browser
 
@@ -4280,9 +4253,9 @@ def test_a_non_finite_constant_outranks_a_lone_surrogate(
         Both arrive at the non-finite sentence, but only a test says so.
         """
         out = _parity_stdout(
-            _accept_domain_src('["\\ud800",NaN]'), tmp_path, "jsonprec",
+            accept_domain_src('["\\ud800",NaN]'), tmp_path, "jsonprec",
         )
-        assert out == "ERR:" + _non_finite_parse_message("NaN")
+        assert out == err(non_finite_parse_message("NaN"))
 
     def test_the_two_walk_refusals_share_one_document_order(
         self, tmp_path: Path,
@@ -4296,11 +4269,11 @@ def test_the_two_walk_refusals_share_one_document_order(
         here.
         """
         assert _parity_stdout(
-            _accept_domain_src('["a\\ud800b",1e999]'), tmp_path, "jsonwalk1",
-        ) == "ERR:" + _lone_surrogate_message(0xD800)
+            accept_domain_src('["a\\ud800b",1e999]'), tmp_path, "jsonwalk1",
+        ) == err(lone_surrogate_message(0xD800))
         assert _parity_stdout(
-            _accept_domain_src('[1e999,"a\\ud800b"]'), tmp_path, "jsonwalk2",
-        ) == "ERR:" + _non_finite_number_message("Infinity")
+            accept_domain_src('[1e999,"a\\ud800b"]'), tmp_path, "jsonwalk2",
+        ) == err(non_finite_number_message("Infinity"))
 
 
 class TestCanonicalNumberFormatMatchesEcmascript1293:
diff --git a/tests/test_json_accept_domain_1306_1308.py b/tests/test_json_accept_domain_1306_1308.py
index c9176e89..59e9991a 100644
--- a/tests/test_json_accept_domain_1306_1308.py
+++ b/tests/test_json_accept_domain_1306_1308.py
@@ -42,10 +42,18 @@
 import pytest
 
 from tests.codegen_helpers import _run_io
+from tests.json_domain_helpers import (
+    ERR_PREFIX,
+    INT_ROUNDS_TO_INFINITY,
+    MAX_FINITE_AS_INT,
+    accept_domain_src,
+    err,
+    ok,
+)
 from vera.wasm.json_serde import (
-    _lone_surrogate_message,
-    _non_finite_number_message,
-    _non_finite_parse_message,
+    lone_surrogate_message,
+    non_finite_number_message,
+    non_finite_parse_message,
     first_domain_violation,
 )
 
@@ -54,39 +62,15 @@
 # ---------------------------------------------------------------------------
 
 
-def _vera_lit(raw: str) -> str:
-    """Escape ``raw`` for embedding in a Vera string literal.
-
-    The battery's inputs are JSON documents full of quotes and
-    backslashes, and one backslash either way changes which bytes
-    ``json_parse`` receives — a surrogate escape and a literal
-    backslash-u sequence differ by exactly that.  Hand-escaping at
-    fifteen call sites is where a test silently stops testing what it
-    claims; converting once at the boundary keeps every case honest.
-    """
-    return raw.replace("\\", "\\\\").replace('"', '\\"')
-
-
-_PROBE = """
-public fn main(@Unit -> @Unit)
-  requires(true) ensures(true) effects()
-{{
-  match json_parse("{text}") {{
-    Ok(@Json) -> IO.print(string_concat("OK:", json_stringify(@Json.0))),
-    Err(@String) -> IO.print(string_concat("ERR:", @String.0))
-  }}
-}}
-"""
-
-
 def _parse_probe(raw_json: str) -> str:
-    """Run ``json_parse(raw_json)`` and report the arm taken.
+    """Run ``json_parse(raw_json)`` on the REFERENCE host and report the arm.
 
-    Returns ``"OK:"`` or ``"ERR:"`` — the same
-    observation the browser battery makes, so the two files compare
-    like with like.
+    The program, the escaping and the output protocol all come from
+    ``tests/json_domain_helpers``, so this battery and the cross-host one
+    in ``tests/test_browser.py`` cannot drift into sending ``json_parse``
+    different bytes while claiming to cover the same case.
     """
-    return _run_io(_PROBE.format(text=_vera_lit(raw_json)))
+    return _run_io(accept_domain_src(raw_json))
 
 
 # The four probe inputs from #1306's table, plus the two-constant case
@@ -184,7 +168,7 @@ class TestNonFiniteParseRefusal1306:
     def test_refused_with_the_shared_sentence(
         self, case_id: str, raw_json: str, name: str,
     ) -> None:
-        assert _parse_probe(raw_json) == "ERR:" + _non_finite_parse_message(name)
+        assert _parse_probe(raw_json) == err(non_finite_parse_message(name))
 
     def test_message_names_the_constant_and_the_remedy(self) -> None:
         """Guards the guard: the sentence has to carry both halves.
@@ -194,7 +178,7 @@ def test_message_names_the_constant_and_the_remedy(self) -> None:
         the user nothing — the assertions above compare the production
         sentence with itself, so the *content* needs its own check.
         """
-        msg = _non_finite_parse_message("NaN")
+        msg = non_finite_parse_message("NaN")
         assert "NaN" in msg
         assert "RFC 8259" in msg
         assert "json_parse:" in msg
@@ -209,7 +193,7 @@ def test_message_names_the_constant_and_the_remedy(self) -> None:
     def test_valid_documents_are_unaffected(
         self, case_id: str, raw_json: str, expected: str,
     ) -> None:
-        assert _parse_probe(raw_json) == "OK:" + expected
+        assert _parse_probe(raw_json) == ok(expected)
 
     def test_malformed_text_keeps_the_host_parser_message(self) -> None:
         """The refinement must not swallow ordinary syntax errors.
@@ -220,8 +204,8 @@ def test_malformed_text_keeps_the_host_parser_message(self) -> None:
         host-native) behaviour for syntax errors.
         """
         out = _parse_probe("{not json")
-        assert out.startswith("ERR:")
-        assert _non_finite_parse_message("NaN") not in out
+        assert out.startswith(ERR_PREFIX)
+        assert non_finite_parse_message("NaN") not in out
         assert "json_parse:" not in out
 
     @pytest.mark.parametrize(
@@ -266,7 +250,7 @@ def test_a_constant_lookalike_is_not_reported_as_non_finite(
         identically.
         """
         out = _parse_probe(raw_json)
-        assert out.startswith("ERR:")
+        assert out.startswith(ERR_PREFIX)
         assert "json_parse:" not in out
 
     def test_a_non_finite_constant_outranks_a_lone_surrogate(self) -> None:
@@ -279,8 +263,8 @@ def test_a_non_finite_constant_outranks_a_lone_surrogate(self) -> None:
         surrogate scan never runs either) — see the parity twin in
         ``tests/test_browser.py``.
         """
-        assert _parse_probe('["\\ud800",NaN]') == (
-            "ERR:" + _non_finite_parse_message("NaN")
+        assert _parse_probe('["\\ud800",NaN]') == err(
+            non_finite_parse_message("NaN"),
         )
 
 
@@ -335,9 +319,7 @@ class TestNonFiniteNumberOverflowRefusal1306:
     def test_overflow_is_refused_with_the_shared_sentence(
         self, case_id: str, raw_json: str, name: str,
     ) -> None:
-        assert _parse_probe(raw_json) == "ERR:" + _non_finite_number_message(
-            name,
-        )
+        assert _parse_probe(raw_json) == err(non_finite_number_message(name))
 
     @pytest.mark.parametrize(
         ("case_id", "raw_json", "expected"),
@@ -353,7 +335,7 @@ def test_finite_numbers_at_the_boundary_still_parse(
         refusal keyed on "large" rather than on "not finite" would take
         it, and take every legitimate scientific document with it.
         """
-        assert _parse_probe(raw_json) == "OK:" + expected
+        assert _parse_probe(raw_json) == ok(expected)
 
     def test_underflow_decodes_to_zero_rather_than_being_refused(
         self,
@@ -366,9 +348,9 @@ def test_underflow_decodes_to_zero_rather_than_being_refused(
         carry, so the domain admits it.  Pinned explicitly because
         "symmetry with overflow" is the plausible wrong answer.
         """
-        assert _parse_probe("1e-999") == "OK:0"
-        assert _parse_probe("[1e-999,1e999]") == (
-            "ERR:" + _non_finite_number_message("Infinity")
+        assert _parse_probe("1e-999") == ok("0")
+        assert _parse_probe("[1e-999,1e999]") == err(
+            non_finite_number_message("Infinity"),
         )
 
     def test_a_constant_outranks_an_overflow(self) -> None:
@@ -379,7 +361,7 @@ def test_a_constant_outranks_an_overflow(self) -> None:
         parses the text at all.  Pinned in both orders so the answer
         cannot depend on which appears first.
         """
-        expected = "ERR:" + _non_finite_parse_message("NaN")
+        expected = err(non_finite_parse_message("NaN"))
         assert _parse_probe("[NaN,1e999]") == expected
         assert _parse_probe("[1e999,NaN]") == expected
 
@@ -394,11 +376,11 @@ def test_document_order_decides_between_the_two_walk_refusals(
         precedence table and that the two hosts cannot implement
         differently.
         """
-        assert _parse_probe('["a\\ud800b",1e999]') == (
-            "ERR:" + _lone_surrogate_message(0xD800)
+        assert _parse_probe('["a\\ud800b",1e999]') == err(
+            lone_surrogate_message(0xD800),
         )
-        assert _parse_probe('[1e999,"a\\ud800b"]') == (
-            "ERR:" + _non_finite_number_message("Infinity")
+        assert _parse_probe('[1e999,"a\\ud800b"]') == err(
+            non_finite_number_message("Infinity"),
         )
 
 
@@ -417,8 +399,6 @@ def test_document_order_decides_between_the_two_walk_refusals(
 # accept it.  A comparison against ``int(sys.float_info.max)`` would
 # refuse it on the reference host alone, trading this divergence for its
 # mirror image.
-_INT_ROUNDS_TO_INFINITY = 2**1024 - 2**970
-_MAX_FINITE_AS_INT = int(sys.float_info.max)
 
 _INT_OVERFLOW_CASES = [
     ("digits_309", "1" + "0" * 309, "Infinity"),
@@ -431,22 +411,22 @@ def test_document_order_decides_between_the_two_walk_refusals(
     ("in_array", "[1" + "0" * 309 + "]", "Infinity"),
     ("in_object", '{"a":1' + "0" * 309 + "}", "Infinity"),
     ("nested", "[[1" + "0" * 309 + "]]", "Infinity"),
-    ("exact_rounding_boundary", str(_INT_ROUNDS_TO_INFINITY), "Infinity"),
-    ("negative_exact_boundary", "-" + str(_INT_ROUNDS_TO_INFINITY),
+    ("exact_rounding_boundary", str(INT_ROUNDS_TO_INFINITY), "Infinity"),
+    ("negative_exact_boundary", "-" + str(INT_ROUNDS_TO_INFINITY),
      "-Infinity"),
 ]
 
 _INT_ACCEPTED_CASES = [
     ("digits_308", "1" + "0" * 308, "1e+308"),
     ("negative_digits_308", "-1" + "0" * 308, "-1e+308"),
-    ("boundary_minus_one", str(_INT_ROUNDS_TO_INFINITY - 1),
+    ("boundary_minus_one", str(INT_ROUNDS_TO_INFINITY - 1),
      "1.7976931348623157e+308"),
-    ("max_finite_as_int", str(_MAX_FINITE_AS_INT),
+    ("max_finite_as_int", str(MAX_FINITE_AS_INT),
      "1.7976931348623157e+308"),
     # The control that separates the rounding boundary from
     # ``sys.float_info.max``: this integer is LARGER than the largest
     # finite double and still rounds to it.
-    ("max_finite_as_int_plus_one", str(_MAX_FINITE_AS_INT + 1),
+    ("max_finite_as_int_plus_one", str(MAX_FINITE_AS_INT + 1),
      "1.7976931348623157e+308"),
     ("ordinary_integer", "42", "42"),
     ("negative_ordinary_integer", "-42", "-42"),
@@ -481,9 +461,7 @@ class TestIntegerOverflowRefusal1306:
     def test_integer_overflow_is_refused_with_the_shared_sentence(
         self, case_id: str, raw_json: str, name: str,
     ) -> None:
-        assert _parse_probe(raw_json) == "ERR:" + _non_finite_number_message(
-            name,
-        )
+        assert _parse_probe(raw_json) == err(non_finite_number_message(name))
 
     @pytest.mark.parametrize(
         ("case_id", "raw_json", "expected"),
@@ -493,7 +471,7 @@ def test_integer_overflow_is_refused_with_the_shared_sentence(
     def test_integers_that_round_into_range_still_parse(
         self, case_id: str, raw_json: str, expected: str,
     ) -> None:
-        assert _parse_probe(raw_json) == "OK:" + expected
+        assert _parse_probe(raw_json) == ok(expected)
 
     def test_the_bound_is_the_rounding_boundary_not_the_largest_double(
         self,
@@ -507,15 +485,15 @@ def test_the_bound_is_the_rounding_boundary_not_the_largest_double(
         sign flipped, and invisible to a battery whose only large case
         is a round number of zeros.
         """
-        assert _MAX_FINITE_AS_INT < _INT_ROUNDS_TO_INFINITY
-        assert first_domain_violation(_MAX_FINITE_AS_INT) is None
-        assert first_domain_violation(_MAX_FINITE_AS_INT + 1) is None
-        assert first_domain_violation(_INT_ROUNDS_TO_INFINITY - 1) is None
-        assert first_domain_violation(_INT_ROUNDS_TO_INFINITY) == (
-            _non_finite_number_message("Infinity")
+        assert MAX_FINITE_AS_INT < INT_ROUNDS_TO_INFINITY
+        assert first_domain_violation(MAX_FINITE_AS_INT) is None
+        assert first_domain_violation(MAX_FINITE_AS_INT + 1) is None
+        assert first_domain_violation(INT_ROUNDS_TO_INFINITY - 1) is None
+        assert first_domain_violation(INT_ROUNDS_TO_INFINITY) == (
+            non_finite_number_message("Infinity")
         )
-        assert first_domain_violation(-_INT_ROUNDS_TO_INFINITY) == (
-            _non_finite_number_message("-Infinity")
+        assert first_domain_violation(-INT_ROUNDS_TO_INFINITY) == (
+            non_finite_number_message("-Infinity")
         )
 
     def test_the_bound_agrees_with_python_s_own_float_conversion(
@@ -529,9 +507,9 @@ def test_the_bound_agrees_with_python_s_own_float_conversion(
         The oracle is ``float()`` itself: below the bound it succeeds,
         at and above it raises.
         """
-        assert float(_INT_ROUNDS_TO_INFINITY - 1) == sys.float_info.max
+        assert float(INT_ROUNDS_TO_INFINITY - 1) == sys.float_info.max
         with pytest.raises(OverflowError):
-            float(_INT_ROUNDS_TO_INFINITY)
+            float(INT_ROUNDS_TO_INFINITY)
 
     def test_a_huge_integer_is_refused_rather_than_raising(self) -> None:
         """The 400-digit case, stated as its own property.
@@ -542,15 +520,15 @@ def test_a_huge_integer_is_refused_rather_than_raising(self) -> None:
         it is.  Asserting the ``Err`` value directly says what must
         happen.
         """
-        assert _parse_probe("1" + "0" * 400) == (
-            "ERR:" + _non_finite_number_message("Infinity")
+        assert _parse_probe("1" + "0" * 400) == err(
+            non_finite_number_message("Infinity"),
         )
 
     def test_a_bool_is_still_not_a_number(self) -> None:
         """``bool`` subclasses ``int``; the int arm must not claim it."""
         assert first_domain_violation(True) is None
         assert first_domain_violation([True, False]) is None
-        assert _parse_probe("[true,false]") == "OK:[true,false]"
+        assert _parse_probe("[true,false]") == ok("[true,false]")
 
 
 # ---------------------------------------------------------------------------
@@ -576,9 +554,7 @@ class TestLoneSurrogateParseRefusal1308:
     def test_refused_with_the_shared_sentence(
         self, case_id: str, raw_json: str, code_point: int,
     ) -> None:
-        assert _parse_probe(raw_json) == "ERR:" + _lone_surrogate_message(
-            code_point,
-        )
+        assert _parse_probe(raw_json) == err(lone_surrogate_message(code_point))
 
     @pytest.mark.parametrize(
         ("case_id", "raw_json", "expected"),
@@ -595,11 +571,11 @@ def test_paired_surrogates_still_parse(
         break every astral character in every document — so these run
         beside the refusals rather than in a separate file.
         """
-        assert _parse_probe(raw_json) == "OK:" + expected
+        assert _parse_probe(raw_json) == ok(expected)
 
     def test_message_names_the_code_point_and_the_remedy(self) -> None:
         """Guards the guard — same reasoning as the #1306 twin."""
-        msg = _lone_surrogate_message(0xD800)
+        msg = lone_surrogate_message(0xD800)
         assert "\\uD800" in msg
         assert "surrogate" in msg
         assert "json_parse:" in msg
@@ -613,9 +589,9 @@ def test_message_normalises_the_hex_casing(self) -> None:
         message and the parity battery can compare across hosts without
         a casing rule.
         """
-        assert _lone_surrogate_message(0xD800) == _lone_surrogate_message(0xD800)
-        assert "\\uD800" in _lone_surrogate_message(0xD800)
-        assert "\\uDFFF" in _lone_surrogate_message(0xDFFF)
+        assert lone_surrogate_message(0xD800) == lone_surrogate_message(0xD800)
+        assert "\\uD800" in lone_surrogate_message(0xD800)
+        assert "\\uDFFF" in lone_surrogate_message(0xDFFF)
 
 
 class TestFirstDomainViolationScan:
@@ -642,7 +618,7 @@ def test_returns_none_for_clean_trees(self) -> None:
 
     def test_finds_a_lone_surrogate_in_a_value(self) -> None:
         assert first_domain_violation({"k": "a\ud800b"}) == (
-            _lone_surrogate_message(0xD800)
+            lone_surrogate_message(0xD800)
         )
 
     def test_finds_a_lone_surrogate_in_a_key(self) -> None:
@@ -652,18 +628,18 @@ def test_finds_a_lone_surrogate_in_a_key(self) -> None:
         and the key route is what #1308's own reproduction used.
         """
         assert first_domain_violation({"a\ud800b": 1.0}) == (
-            _lone_surrogate_message(0xD800)
+            lone_surrogate_message(0xD800)
         )
 
     def test_finds_a_non_finite_number(self) -> None:
         assert first_domain_violation(float("inf")) == (
-            _non_finite_number_message("Infinity")
+            non_finite_number_message("Infinity")
         )
         assert first_domain_violation(float("-inf")) == (
-            _non_finite_number_message("-Infinity")
+            non_finite_number_message("-Infinity")
         )
         assert first_domain_violation({"a": [1.0, float("inf")]}) == (
-            _non_finite_number_message("Infinity")
+            non_finite_number_message("Infinity")
         )
 
     def test_nan_is_covered_though_no_json_text_decodes_to_one(self) -> None:
@@ -675,7 +651,7 @@ def test_nan_is_covered_though_no_json_text_decodes_to_one(self) -> None:
         an unreachable branch is where a wrong answer survives.
         """
         assert first_domain_violation(float("nan")) == (
-            _non_finite_number_message("NaN")
+            non_finite_number_message("NaN")
         )
 
     def test_an_integer_in_range_is_not_a_violation(self) -> None:
@@ -695,7 +671,7 @@ def test_an_integer_in_range_is_not_a_violation(self) -> None:
         assert first_domain_violation(10**300) is None
         assert first_domain_violation([1, 2, 3]) is None
         assert first_domain_violation(10**400) == (
-            _non_finite_number_message("Infinity")
+            non_finite_number_message("Infinity")
         )
 
     def test_a_bool_is_not_read_as_a_number(self) -> None:
@@ -704,26 +680,26 @@ def test_a_bool_is_not_read_as_a_number(self) -> None:
 
     def test_key_is_checked_before_its_own_value(self) -> None:
         assert first_domain_violation({"\ud800": "\udc00"}) == (
-            _lone_surrogate_message(0xD800)
+            lone_surrogate_message(0xD800)
         )
 
     def test_earlier_entry_wins_over_later(self) -> None:
         assert first_domain_violation({"a": "\udc00", "b": "\ud800"}) == (
-            _lone_surrogate_message(0xDC00)
+            lone_surrogate_message(0xDC00)
         )
 
     def test_array_order_is_document_order(self) -> None:
         assert first_domain_violation(["ok", "\udfff", "\ud800"]) == (
-            _lone_surrogate_message(0xDFFF)
+            lone_surrogate_message(0xDFFF)
         )
 
     def test_the_two_kinds_share_one_document_order(self) -> None:
         """Whichever comes first names the refusal — no precedence table."""
         assert first_domain_violation(["\ud800", float("inf")]) == (
-            _lone_surrogate_message(0xD800)
+            lone_surrogate_message(0xD800)
         )
         assert first_domain_violation([float("inf"), "\ud800"]) == (
-            _non_finite_number_message("Infinity")
+            non_finite_number_message("Infinity")
         )
 
     def test_boundary_code_points(self) -> None:
@@ -735,8 +711,8 @@ def test_boundary_code_points(self) -> None:
         assert first_domain_violation("\ud7ff") is None
         assert first_domain_violation("\ue000") is None
         assert first_domain_violation("\ud800") == (
-            _lone_surrogate_message(0xD800)
+            lone_surrogate_message(0xD800)
         )
         assert first_domain_violation("\udfff") == (
-            _lone_surrogate_message(0xDFFF)
+            lone_surrogate_message(0xDFFF)
         )
diff --git a/tests/test_runtime_traps.py b/tests/test_runtime_traps.py
index 9becc346..bc9e0228 100644
--- a/tests/test_runtime_traps.py
+++ b/tests/test_runtime_traps.py
@@ -36,6 +36,7 @@
 import pytest
 
 from vera.cli import cmd_run
+from vera.codegen.api import CompileResult
 from vera.runtime.traps import (
     WasmTrapError,
     _classify_host_error,
@@ -3030,7 +3031,7 @@ class TestHostErrorDebugKnob1302:
 
     _SRC = TestHostCallbackErrorSurface1302._SRC
 
-    def _compile(self, tmp_path: Path) -> object:
+    def _compile(self, tmp_path: Path) -> CompileResult:
         from vera.codegen import compile as codegen_compile
         from vera.parser import parse_file
         from vera.transform import transform
@@ -3052,7 +3053,7 @@ def test_the_knob_re_raises_the_original_exception(
         monkeypatch.setenv("VERA_DEBUG_HOST_ERRORS", "1")
         result = self._compile(tmp_path)
         with pytest.raises(ValueError) as excinfo:
-            execute(result)  # type: ignore[arg-type]
+            execute(result)
         assert not isinstance(excinfo.value, WasmTrapError)
         assert "json_stringify: NaN is not representable" in str(excinfo.value)
 
@@ -3070,14 +3071,14 @@ def test_without_the_knob_the_conversion_still_happens(
         monkeypatch.delenv("VERA_DEBUG_HOST_ERRORS", raising=False)
         result = self._compile(tmp_path)
         with pytest.raises(WasmTrapError) as excinfo:
-            execute(result)  # type: ignore[arg-type]
+            execute(result)
         assert excinfo.value.kind == "host_error"
 
     @pytest.mark.parametrize(
         ("value", "enabled"),
         [("1", True), ("true", True), ("TRUE", True), ("yes", True),
-         (" 1 ", True), ("0", False), ("", False), ("no", False),
-         ("false", False)],
+         ("on", True), ("ON", True), (" 1 ", True), ("0", False),
+         ("", False), ("no", False), ("false", False), ("off", False)],
     )
     def test_the_knob_accepts_the_same_spellings_as_vera_eager_gc(
         self, value: str, enabled: bool, tmp_path: Path,
@@ -3085,9 +3086,17 @@ def test_the_knob_accepts_the_same_spellings_as_vera_eager_gc(
     ) -> None:
         """One truthiness rule across the ``VERA_*`` diagnostic knobs.
 
-        ``VERA_EAGER_GC`` accepts ``1`` / ``true`` / ``yes``
-        case-insensitively after stripping; a second knob reading its
-        value differently is a trap for anyone who learned the first.
+        Both knobs now read ``vera.envflags.flag_enabled``, so this
+        measures the shared implementation rather than a second copy of
+        the rule.  Unifying them found that they had already drifted:
+        ``VERA_EAGER_GC`` accepted ``on`` and this one did not, and
+        neither ENVIRONMENT.md section said so.  The shared set is their
+        union — widening the narrower knob is safe, where narrowing the
+        wider one would quietly stop honouring ``VERA_EAGER_GC=on``.
+
+        ``off`` is in the table as a negative: it looks like a spelling
+        of the flag and means not-set, which is the reading a user gets
+        wrong in the direction that matters.
         """
         from vera.codegen import execute
 
@@ -3095,7 +3104,7 @@ def test_the_knob_accepts_the_same_spellings_as_vera_eager_gc(
         result = self._compile(tmp_path)
         expected: type[BaseException] = ValueError if enabled else WasmTrapError
         with pytest.raises(expected):
-            execute(result)  # type: ignore[arg-type]
+            execute(result)
 
     def test_the_cli_prints_a_traceback_under_the_knob(
         self, tmp_path: Path, capsys: CaptureFixture[str],
@@ -3115,6 +3124,96 @@ def test_the_cli_prints_a_traceback_under_the_knob(
             cmd_run(str(path))
 
 
+class TestHostCallbackBaseExceptionBoundary1302:
+    """The two exceptions the #1302 conversion must NOT swallow.
+
+    ``execute()`` now converts everything escaping the guest invocation,
+    which is the point — but its handler is ``except Exception``, and
+    two ``BaseException`` subclasses deliberately sit outside it.
+    ``SystemExit`` is a request to end the process rather than a failed
+    operation, and
+    ``KeyboardInterrupt`` has its own handler that maps Ctrl-C to exit
+    130 with the captured output intact (#595 / #599).  Converting
+    either into a ``host_error`` would turn a control-flow signal into a
+    diagnostic.
+
+    Nothing in the current code catches them, so both tests pass today.
+    They are here for the edit that would break them: widening the
+    handler to ``except BaseException`` — the obvious "make it really
+    catch everything" change, and one that reads as an improvement right
+    up until Ctrl-C stops working.  The existing Ctrl-C suite drives
+    ``IO.sleep``, whose binding has its own interrupt path; these drive a
+    binding on the ordinary host-callback route, which is the one the
+    conversion sits on.
+
+    Both raise from inside a real host callback rather than from a
+    synthetic harness, by monkeypatching the function ``register_json``
+    imports per call, so the exception travels the true path: through
+    wasmtime's trampoline and out of ``func(store, *call_args)``.
+    """
+
+    _SRC = TestHostCallbackErrorSurface1302._SRC
+
+    def _compile(self, tmp_path: Path) -> CompileResult:
+        from vera.codegen import compile as codegen_compile
+        from vera.parser import parse_file
+        from vera.transform import transform
+
+        path = tmp_path / "baseexc.vera"
+        path.write_text(self._SRC, encoding="utf-8")
+        result = codegen_compile(
+            transform(parse_file(str(path))),
+            source=self._SRC,
+            file=str(path),
+        )
+        assert result.ok
+        return result
+
+    def test_system_exit_from_a_host_callback_is_not_converted(
+        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
+    ) -> None:
+        """It exits per ``SystemExit``, not as ``kind="host_error"``."""
+        import vera.wasm.json_serde as json_serde
+        from vera.codegen import execute
+
+        def _exit(_value: object) -> str:
+            raise SystemExit(3)
+
+        monkeypatch.setattr(json_serde, "dumps_canonical", _exit)
+        result = self._compile(tmp_path)
+
+        with pytest.raises(SystemExit) as excinfo:
+            execute(result)
+        assert not isinstance(excinfo.value, WasmTrapError)
+        assert excinfo.value.code == 3
+
+    def test_keyboard_interrupt_from_a_host_callback_still_exits_130(
+        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
+    ) -> None:
+        """Ctrl-C keeps its own handler, on the host-callback route too.
+
+        The pre-existing ``TestHostSleepKeyboardInterrupt`` cases go
+        through ``IO.sleep`` and friends; this one goes through the same
+        callback route the #1302 conversion guards, so a widened handler
+        that stole ``KeyboardInterrupt`` would be caught here even if the
+        sleep path were left alone.
+        """
+        import vera.wasm.json_serde as json_serde
+        from vera.codegen import execute
+
+        def _interrupt(_value: object) -> str:
+            raise KeyboardInterrupt
+
+        monkeypatch.setattr(json_serde, "dumps_canonical", _interrupt)
+        result = self._compile(tmp_path)
+
+        exec_result = execute(result)
+        assert exec_result.exit_code == 130
+        # #522's contract holds through the interrupt: what the program
+        # printed before Ctrl-C is still returned.
+        assert exec_result.stdout == "before"
+
+
 class TestClassifyHostError1302:
     """Unit tests for the host-callback classifier (#1302)."""
 
diff --git a/vera/README.md b/vera/README.md
index aed9e7e5..41d038ef 100644
--- a/vera/README.md
+++ b/vera/README.md
@@ -154,15 +154,16 @@ execute(compile_result, ...)    # → run WASM via wasmtime
 | `errors.py` | 813 | All | Diagnostic class, error hierarchy, error code registry | `Diagnostic`, `VeraError`, `ERROR_CODES` |
 | `skip.py` | 241 | All | Codegen-internal control-flow exceptions behind structured skip diagnostics (#626) | `CodegenSkip`, `CodegenInvariantError` |
 | `introspect.py` | 127 | All | Payloads for `vera builtins` / `effects` / `errors --json` | `builtins_payload()`, `effects_payload()`, `errors_payload()` |
+| `envflags.py` | 35 | All | One truthiness rule for the `VERA_*` diagnostic flags catalogued in ENVIRONMENT.md; a leaf module (imports `os` only) so any layer can read a flag without a cycle | `flag_enabled()` |
 | `_since.py` | 376 | All | Best-effort `since` version attribution for built-ins, effects, abilities | |
 | `browser/` | 138 | Execute | Browser runtime for compiled WASM (package) | `emit_browser_bundle()` |
 | ` ├ emit.py` | 137 | | Browser bundle emission (wasm + runtime + html) | `emit_browser_bundle()` |
-| ` ├ runtime.mjs` | 3,660 | | Self-contained JS runtime: IO, State, Http, Inference, contracts, Markdown, Json, Html | |
+| ` ├ runtime.mjs` | 3,877 | | Self-contained JS runtime: IO, State, Http, Inference, contracts, Markdown, Json, Html | |
 | ` └ harness.mjs` | 106 | | Node.js test harness for parity testing | |
 | `cli.py` | 1,990 | All | CLI commands | `main()` |
 | `registration.py` | 126 | Type check | Shared function registration | `register_fn()` |
 
-Total: ~88,000 lines of Python + 344 lines of grammar + 3,409 lines of JavaScript.
+Total: ~88,000 lines of Python + 344 lines of grammar + 3,983 lines of JavaScript.
 
 ## Parsing
 
@@ -589,7 +590,7 @@ The WASM import interface is the portability contract: the compiled `.wasm` bina
 
 ### Browser runtime
 
-`browser/runtime.mjs` is a self-contained JavaScript runtime (~3,810 lines) that provides JavaScript implementations of all Vera host bindings. It works with **any** compiled Vera `.wasm` module — no code generation needed.
+`browser/runtime.mjs` is a self-contained JavaScript runtime (~3,877 lines) that provides JavaScript implementations of all Vera host bindings. It works with **any** compiled Vera `.wasm` module — no code generation needed.
 
 **Dynamic import introspection:** Instead of generating per-program glue code, the runtime uses `WebAssembly.Module.imports(module)` at initialization to discover which host functions the module actually needs, then builds the import object dynamically. State\ types are pattern-matched from `state_get_*`/`state_put_*` import names.
 
diff --git a/vera/browser/runtime.mjs b/vera/browser/runtime.mjs
index 92e4f758..37b60405 100644
--- a/vera/browser/runtime.mjs
+++ b/vera/browser/runtime.mjs
@@ -2974,8 +2974,8 @@ function buildImportObject(module, moduleBytes) {
   //     both are decided on the decoded value by one walk.
   //
   // All three sentences are hand-copied from ``vera/wasm/json_serde.py``
-  // (``_non_finite_parse_message`` / ``_lone_surrogate_message`` /
-  // ``_non_finite_number_message``) and held against those originals by
+  // (``non_finite_parse_message`` / ``lone_surrogate_message`` /
+  // ``non_finite_number_message``) and held against those originals by
   // tests/test_browser.py.
 
   const NON_FINITE_TOKENS = ["-Infinity", "Infinity", "NaN"];
diff --git a/vera/codegen/api.py b/vera/codegen/api.py
index 6fc2c1c6..cec8ee59 100644
--- a/vera/codegen/api.py
+++ b/vera/codegen/api.py
@@ -51,6 +51,7 @@
 from vera.runtime.md import register_md
 from vera.runtime.random import register_random
 from vera.runtime.regex import register_regex
+from vera.envflags import flag_enabled
 from vera.runtime.set import register_set
 from vera.runtime.state import register_state
 from vera.runtime.traps import (
@@ -1310,10 +1311,23 @@ def _alloc_string_arg(s: str) -> tuple[int, int]:
         # ``except RuntimeError`` blocks remain backward-compatible.
         # #1302 — the conversion is keyed on the BOUNDARY, not on the
         # exception's type.  The guarded region is the guest invocation
-        # and nothing else, so anything arriving here is either a
-        # wasmtime trap or a host callback that raised: wasmtime's
-        # trampoline re-raises a host-import exception unchanged, and
-        # every compiler phase has already finished.  Keying on the type
+        # plus the executor teardown in its ``finally``, so anything
+        # arriving here is a wasmtime trap, a host callback that raised
+        # (wasmtime's trampoline re-raises those unchanged), or a
+        # failure tearing the worker pool down.  Every compiler phase
+        # has already finished, so none of it can be a compiler bug
+        # reaching the user as a Vera error.
+        #
+        # The teardown is inside the boundary deliberately.  Moving it
+        # out while keeping #841's "runs on every exit path" contract
+        # means attaching the ``finally`` to THIS try instead, which
+        # inverts the order: the handler below would read
+        # ``output_buf`` before the pool is joined, losing whatever an
+        # in-flight worker writes during ``shutdown(wait=True)``.
+        # Leaving it inside costs little — ``shutdown`` does not
+        # surface worker exceptions (those stay on their futures), so a
+        # failure there is a genuine host-side failure during execution
+        # and ``host_error`` is the right shape for it.  Keying on the type
         # name instead meant a ``ValueError`` from a host binding — a
         # deliberate refusal like ``json_stringify``'s on a non-finite
         # number — skipped the branch entirely and escaped as a raw
@@ -1341,9 +1355,7 @@ def _alloc_string_arg(s: str) -> tuple[int, int]:
             # person reading a terminal.  Setting the variable re-raises
             # the original untouched.  Same truthiness rule as
             # ``VERA_EAGER_GC``, the other ``VERA_*`` diagnostic knob.
-            if os.environ.get("VERA_DEBUG_HOST_ERRORS", "").strip().lower() in (
-                "1", "true", "yes",
-            ):
+            if flag_enabled("VERA_DEBUG_HOST_ERRORS"):
                 raise
             kind, message, fix = _classify_host_error(exc)
         # #516 Stage 2 — resolve trap frames against the source map.
diff --git a/vera/codegen/assembly.py b/vera/codegen/assembly.py
index 942a80f9..3fcec772 100644
--- a/vera/codegen/assembly.py
+++ b/vera/codegen/assembly.py
@@ -6,8 +6,7 @@
 
 from __future__ import annotations
 
-import os
-
+from vera.envflags import flag_enabled
 from vera.monomorphize import mangle_type_name
 from vera.skip import CodegenInvariantError
 from vera.wasm.helpers import MAX_INLINE_I32_VALUE
@@ -652,9 +651,7 @@ def _emit_alloc(self) -> str:
         orders of magnitude slower than normal — never enable in
         production.
         """
-        eager = os.environ.get("VERA_EAGER_GC", "").strip().lower() in (
-            "1", "true", "yes", "on",
-        )
+        eager = flag_enabled("VERA_EAGER_GC")
         eager_prefix = (
             "    ;; VERA_EAGER_GC=1 — force GC on every alloc to surface\n"
             "    ;; missing shadow-stack roots (debugging knob, see\n"
diff --git a/vera/envflags.py b/vera/envflags.py
new file mode 100644
index 00000000..59b212da
--- /dev/null
+++ b/vera/envflags.py
@@ -0,0 +1,35 @@
+"""Reading the `VERA_*` diagnostic flags.
+
+One predicate, so the knobs catalogued in `ENVIRONMENT.md` agree about
+what "set" means.  Two of them are read from opposite ends of the
+compiler — `VERA_EAGER_GC` in `vera/codegen/assembly.py` at emit time,
+`VERA_DEBUG_HOST_ERRORS` in `vera/codegen/api.py` at execution time —
+and a second copy of the parsing rule is how one of them quietly starts
+accepting a spelling the other rejects, in a variable a user only ever
+sets while something is already going wrong.
+
+Deliberately a leaf: this module imports `os` and nothing from `vera`,
+so any layer can read a flag without an import cycle.
+"""
+
+from __future__ import annotations
+
+import os
+
+# The spellings that mean "on".  Compared after stripping surrounding
+# whitespace and lowercasing, so ` TRUE ` counts.  Anything else —
+# including `0`, `no`, `false` and the empty string — means off, so a
+# variable left set to `0` in a shell profile does not silently enable a
+# debugging mode.
+#
+# The set is the UNION of what the two read sites accepted before they
+# were unified: `VERA_EAGER_GC` took `on` and `VERA_DEBUG_HOST_ERRORS`
+# did not, and neither ENVIRONMENT.md section mentioned it.  Widening
+# the narrower knob is safe; narrowing the wider one would quietly stop
+# honouring `VERA_EAGER_GC=on` for whoever is already typing it.
+_TRUTHY = ("1", "true", "yes", "on")
+
+
+def flag_enabled(name: str) -> bool:
+    """Is the `VERA_*` diagnostic flag ``name`` set to a truthy value?"""
+    return os.environ.get(name, "").strip().lower() in _TRUTHY
diff --git a/vera/runtime/json.py b/vera/runtime/json.py
index 98038706..8d791c59 100644
--- a/vera/runtime/json.py
+++ b/vera/runtime/json.py
@@ -30,7 +30,7 @@ def register_json(linker: wasmtime.Linker, ops_used: set[str]) -> None:
     import json as _json
 
     from vera.wasm.json_serde import (
-        _non_finite_parse_message,
+        non_finite_parse_message,
         dumps_canonical,
         first_domain_violation,
         read_json,
@@ -81,7 +81,7 @@ def record_non_finite(name: str) -> float:
                 return _alloc_result_err_string(caller, str(exc))
             if seen_constant:
                 return _alloc_result_err_string(
-                    caller, _non_finite_parse_message(seen_constant[0]),
+                    caller, non_finite_parse_message(seen_constant[0]),
                 )
             # The two value-level exclusions, in one document-order
             # walk.  A lone surrogate is not a Unicode scalar value, so
diff --git a/vera/wasm/json_serde.py b/vera/wasm/json_serde.py
index 194a2477..71d68885 100644
--- a/vera/wasm/json_serde.py
+++ b/vera/wasm/json_serde.py
@@ -19,9 +19,9 @@
 
 Accept domain (spec §9.7.1, #1306 / #1308):
   first_domain_violation(value) → str | None
-  _non_finite_parse_message(name) → str
-  _non_finite_number_message(name) → str
-  _lone_surrogate_message(code_point) → str
+  non_finite_parse_message(name) → str
+  non_finite_number_message(name) → str
+  lone_surrogate_message(code_point) → str
 
 The domain gates sit in front of the write direction rather than inside
 it: `vera/runtime/json.py` consults them on the value `json.loads`
@@ -103,7 +103,7 @@
 # different things about the same input.
 
 
-def _non_finite_parse_message(name: str) -> str:
+def non_finite_parse_message(name: str) -> str:
     """The single sentence both runtimes return for a bare ``NaN``.
 
     ``name`` is the constant as it appears in the text — ``"NaN"``,
@@ -118,7 +118,7 @@ def _non_finite_parse_message(name: str) -> str:
     )
 
 
-def _lone_surrogate_message(code_point: int) -> str:
+def lone_surrogate_message(code_point: int) -> str:
     """The single sentence both runtimes return for a lone surrogate.
 
     The code point is rendered in the canonical ``\\uXXXX`` escape form
@@ -154,10 +154,10 @@ def _first_lone_surrogate_in_str(text: str) -> int | None:
             return code_point
     return None
 
-def _non_finite_number_message(name: str) -> str:
+def non_finite_number_message(name: str) -> str:
     """The single sentence both runtimes return for an overflowing number.
 
-    The sibling of :func:`_non_finite_parse_message`: the same exclusion
+    The sibling of :func:`non_finite_parse_message`: the same exclusion
     — no accepted text decodes to a non-finite number — reached by a
     different syntax.  ``1e999`` breaks no RFC 8259 rule, so this one
     cites the permission the refusal rests on rather than a prohibition.
@@ -239,10 +239,10 @@ def first_domain_violation(value: Any) -> str | None:
         code_point = _first_lone_surrogate_in_str(value)
         if code_point is None:
             return None
-        return _lone_surrogate_message(code_point)
+        return lone_surrogate_message(code_point)
     if isinstance(value, float):
         if value != value or value in (float("inf"), float("-inf")):
-            return _non_finite_number_message(_NON_FINITE_NAMES[repr(value)])
+            return non_finite_number_message(_NON_FINITE_NAMES[repr(value)])
         return None
     if isinstance(value, int) and not isinstance(value, bool):
         # ``bool`` subclasses ``int``; a boolean is a JSON boolean and
@@ -250,9 +250,9 @@ def first_domain_violation(value: Any) -> str | None:
         # arithmetic all the way down, so a 400-digit literal is refused
         # rather than raising on its way to being measured.
         if value >= _INT_ROUNDS_TO_INFINITY:
-            return _non_finite_number_message("Infinity")
+            return non_finite_number_message("Infinity")
         if value <= -_INT_ROUNDS_TO_INFINITY:
-            return _non_finite_number_message("-Infinity")
+            return non_finite_number_message("-Infinity")
         return None
     if isinstance(value, list):
         for item in value:
@@ -265,7 +265,7 @@ def first_domain_violation(value: Any) -> str | None:
             if isinstance(key, str):
                 code_point = _first_lone_surrogate_in_str(key)
                 if code_point is not None:
-                    return _lone_surrogate_message(code_point)
+                    return lone_surrogate_message(code_point)
             found = first_domain_violation(item)
             if found is not None:
                 return found

From 649f766ee429c9ad1fe08fa54451c205d66484ba Mon Sep 17 00:00:00 2001
From: Alasdair Allan 
Date: Fri, 14 Aug 2026 16:24:23 +0100
Subject: [PATCH 42/69] Close CodeRabbit wave 2: a vacuous assertion, and three
 stale surfaces
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The casing test compared `lone_surrogate_message(0xD800)` with itself, so
it asserted nothing at all. What it meant to say — that the two input
spellings reach the same sentence — is carried end to end by the
`value_lower` / `value_upper` pairs, which parse real text; the helper
takes a code point and cannot observe an input spelling. The function
keeps only what it can actually check, the uppercase rendering, and says
where the real property lives.

The compiler README's parity enumeration listed the constants and the
lone surrogate but not either overflow class, which is most of what the
battery grew. It now names both spellings and the controls that make the
bound a boundary — including the band between the largest finite double
and the rounding boundary, whose integers exceed `sys.float_info.max`
and are still accepted by both hosts.

README's conformance figure was stale at 229 against a 230-entry
manifest. `check_doc_counts` gates the tests count on that line but not
the conformance count beside it, which is why it drifted through two
rebases without a gate noticing.

`non_finite_number_message` was separated from the function above it by
one blank line where PEP 8 wants two.

Co-Authored-By: Claude 
---
 AGENTS.md                                  |  8 ++---
 CLAUDE.md                                  |  8 ++---
 FAQ.md                                     |  4 +--
 README.md                                  |  2 +-
 ROADMAP.md                                 |  2 +-
 SKILL.md                                   |  2 +-
 TESTING.md                                 | 36 +++++++++++-----------
 docs/SKILL.md                              |  2 +-
 docs/index.html                            |  2 +-
 docs/index.md                              |  2 +-
 docs/llms-full.txt                         | 14 ++++-----
 docs/llms.txt                              |  2 +-
 tests/test_json_accept_domain_1306_1308.py | 18 ++++++-----
 vera/README.md                             |  4 +--
 vera/wasm/json_serde.py                    |  1 +
 15 files changed, 55 insertions(+), 52 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md
index ddb69c31..bf583d7e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -8,7 +8,7 @@ Read `SKILL.md` for the full language reference. It covers syntax, slot referenc
 
 ### Conformance programs as reference
 
-The conformance suite in `tests/conformance/` contains 238 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-seven negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) instead must *fail* with the E-code in their `expected_error` field, at the stage their `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec.
+The conformance suite in `tests/conformance/` contains 239 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-seven negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) instead must *fail* with the E-code in their `expected_error` field, at the stage their `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec.
 
 ### Workflow
 
@@ -187,9 +187,9 @@ Each stage is a module with a single public API function (`parse_file`, `transfo
 pytest tests/ -v                       # Run all tests (see TESTING.md)
 pytest tests/test_conformance.py -v    # Conformance suite only
 mypy vera/                             # Type-check the compiler
-python scripts/check_conformance.py    # All 238 conformance programs hold (positives pass; negatives fail with their E-code)
+python scripts/check_conformance.py    # All 239 conformance programs hold (positives pass; negatives fail with their E-code)
 python scripts/check_examples.py       # All 42 examples must pass
-python scripts/check_corpus_canonical.py # All 287 corpus programs in canonical form
+python scripts/check_corpus_canonical.py # All 288 corpus programs in canonical form
 ```
 
 Test helpers follow a pattern: `_check_ok(source)` / `_check_err(source, match)` / `_verify_ok(source)` / `_verify_err(source, match)`. See existing tests for examples.
@@ -198,7 +198,7 @@ When implementing a new language feature, write the conformance program *first*
 
 ### Invariants
 
-- All 238 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first
+- All 239 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first
 - All 42 examples in `examples/` must pass `vera check` and `vera verify`
 - `mypy vera/` must be clean
 - `pytest tests/ -v` must pass
diff --git a/CLAUDE.md b/CLAUDE.md
index 77d43898..0ed48c07 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -59,10 +59,10 @@ VERA_EAGER_GC=1 vera run file.vera  # Force GC on every alloc (see ENVIRONMENT.m
 VERA_DEBUG_HOST_ERRORS=1 vera run file.vera  # Re-raise a host callback's own exception (see ENVIRONMENT.md, debug knob for host-binding bugs)
 mypy vera/                        # Type-check the compiler itself
 
-python scripts/check_conformance.py    # Verify all 238 conformance programs (positives pass their level; negatives fail with their expected_error E-code)
+python scripts/check_conformance.py    # Verify all 239 conformance programs (positives pass their level; negatives fail with their expected_error E-code)
 python scripts/check_examples.py      # Verify all 42 examples parse + check + verify
 python scripts/check_examples_run.py  # Run every runnable example trap-free under the native runtime; the rest carry a documented skip property, and an example that is neither is an error
-python scripts/check_corpus_canonical.py # Verify all 287 corpus programs are in canonical form (vera fmt)
+python scripts/check_corpus_canonical.py # Verify all 288 corpus programs are in canonical form (vera fmt)
 python scripts/check_examples_readme.py # Verify vera run commands in examples/README.md
 python scripts/check_spec_examples.py # Verify spec code blocks parse
 python scripts/check_readme_examples.py # Verify README code blocks parse
@@ -94,7 +94,7 @@ See [`TOOLCHAIN.md`](TOOLCHAIN.md) for the CLI cookbook — driving the toolchai
 - `vera/` — Reference compiler: grammar, parser, AST, transformer, type checker, verifier, codegen, CLI
 - `examples/` — 42 example Vera programs (all must pass `vera check` and `vera verify`)
 - `tests/` — Test suite (unit tests + conformance suite)
-- `tests/conformance/` — 238 conformance programs validating every language feature against the spec
+- `tests/conformance/` — 239 conformance programs validating every language feature against the spec
 - `scripts/` — CI and validation scripts
 
 ## Writing Vera code
@@ -131,7 +131,7 @@ Before changing code — **adding or removing** — write the test that proves y
 ## What not to break
 
 - Pre-commit hooks run mypy + pytest + conformance suite + example validation on every commit
-- All 238 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first
+- All 239 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first
 - All 42 examples in `examples/` must pass `vera check` and `vera verify`
 - Version must stay in sync across `pyproject.toml`, `vera/__init__.py`, `docs/index.html`, `README.md`, and `uv.lock` (gated by `scripts/check_version_sync.py`); CHANGELOG.md must also carry a matching `## [X.Y.Z]` section
 - All tests must pass: `pytest tests/ -v`
diff --git a/FAQ.md b/FAQ.md
index 391ce369..167bbbe0 100644
--- a/FAQ.md
+++ b/FAQ.md
@@ -236,7 +236,7 @@ None of this is Vera-specific, but it validates the design choices. The thesis i
 
 This is a real concern. LLMs are trained on trillions of tokens of Python, TypeScript, and JavaScript. A MojoBench study (NAACL 2025) found that even fine-tuned models achieved only 30–35% improvement over base models on Mojo code generation, illustrating the cold-start problem for new languages.
 
-Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (238 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification.
+Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (239 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification.
 
 
 ## How does Vera compare to Dafny / Lean / Koka / F*?
@@ -279,7 +279,7 @@ The reference compiler is under active development. The current release includes
 
 - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute
 - A 14-chapter formal specification
-- 11,237 tests, including a 238-program conformance suite
+- 11,434 tests, including a 239-program conformance suite
 - 42 working example programs
 - 164 built-in functions covering strings, arrays, math, parsing, and data types
 - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation
diff --git a/README.md b/README.md
index 8c2f846c..bcdf2061 100644
--- a/README.md
+++ b/README.md
@@ -263,7 +263,7 @@ cp /path/to/vera/SKILL.md ~/.claude/skills/vera-language/SKILL.md
 
 ## Project status
 
-Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 11,237 tests, 95% Python code coverage, 238 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built.
+Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 11,434 tests, 95% Python code coverage, 238 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built.
 
 The reference compiler — parser, AST, type checker, contract verifier (Z3), WASM code generator, module system, browser runtime, and runtime contract insertion — is working. The language specification is in draft across [14 chapters](spec/).
 
diff --git a/ROADMAP.md b/ROADMAP.md
index 758f2b54..dc8add0b 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -12,7 +12,7 @@ Ordering derives from the design principles ([DESIGN.md](DESIGN.md)): verificati
 
 ## Where we are
 
-11,237 tests, 238 conformance programs, 42 examples, 14 spec chapters.  [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire.
+11,434 tests, 239 conformance programs, 42 examples, 14 spec chapters.  [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire.
 
 ## Stage 19 — The verification completeness sprint
 
diff --git a/SKILL.md b/SKILL.md
index b71d5d89..9fd95242 100644
--- a/SKILL.md
+++ b/SKILL.md
@@ -2461,7 +2461,7 @@ public fn main(@Unit -> @Unit)
 
 ## Conformance Suite
 
-The `tests/conformance/` directory contains 238 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics.
+The `tests/conformance/` directory contains 239 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics.
 
 Each program is organized by spec chapter (`ch01_int_literals.vera`, `ch04_match_basic.vera`, `ch07_state_handler.vera`, etc.) and the `manifest.json` file maps features to programs. When you need to see how a specific construct works, check the conformance program before reading the spec.
 
diff --git a/TESTING.md b/TESTING.md
index 339922e6..be39ed26 100644
--- a/TESTING.md
+++ b/TESTING.md
@@ -6,9 +6,9 @@ This is the single source of truth for Vera's testing infrastructure, coverage d
 
 | Metric | Value |
 |--------|-------|
-| **Tests** | 11,237 across 170 files (~155,000 lines of test code; 11,042 passed + 26 stress, 169 skipped) |
+| **Tests** | 11,434 across 171 files (~155,000 lines of test code; 11,238 passed + 26 stress, 170 skipped) |
 | **Compiler code coverage** | 95% Python, 87% JavaScript (CI minimum: 80%) |
-| **Conformance programs** | 238 programs across 9 spec chapters, validating every language feature |
+| **Conformance programs** | 239 programs across 9 spec chapters, validating every language feature |
 | **Example programs** | 42, all validated through `vera check` + `vera verify` |
 | **Spec code blocks** | 189 parseable blocks from 14 spec chapters: 92 parse, 86 type-check, 85 verify (the rest carry inline `vera:skip` annotations, #538) |
 | **README code blocks** | 4 Vera blocks (4 validated, 0 annotated) |
@@ -43,7 +43,7 @@ pytest tests/test_runtime_traps.py::TestHostErrorDebugKnob1302 -v
 mypy vera/                                           # strict mode
 
 # Validation scripts
-python scripts/check_conformance.py                  # conformance suite (238 programs, see manifest.json)
+python scripts/check_conformance.py                  # conformance suite (239 programs, see manifest.json)
 python scripts/check_examples.py                     # 42 example programs
 python scripts/check_spec_examples.py                # spec code blocks
 python scripts/check_readme_examples.py              # README code blocks
@@ -104,7 +104,7 @@ python scripts/check_wheel_availability.py           # pre-flight: every runtime
 | `test_checker_errors.py` | 73 | 1,196 | Error codes, resolution-coverage diagnostics, contracts, error accumulation (#420 split); cyclic type aliases incl. #1059 self-reference through a type argument (`Future`, mutual `Future`/`Future`, `Array`) rejected E132 |
 | `test_checker_builtins_collections.py` | 97 | 848 | Map / Set / Decimal / Json / Html / Http / Inference built-in type-checking (#420 split) |
 | `test_checker_builtins_strings.py` | 122 | 945 | String / numeric / type-conversion / float-predicate / string-search / markdown / regex built-in type-checking, removed-legacy-name regression (#420 split) |
-| `test_obligations.py` | 757 | 1,741 | Reified proof obligations + warm `VerificationSession` (#222 Phase A): full-corpus differential oracle (warm session == cold `verify()` on diagnostics, summary, and obligation stream, plus warm-twice determinism, across all 42 examples and every verify/run-level conformance program), summary↔obligation tier-bookkeeping consistency (including the #967 `total == tier1_verified + tier3_runtime` leg, plus a focused self-consistency pin on the three call-demotion examples), the #1242 stream partition — over a corpus widened to every conformance program that type-checks, at any level, `len(obligations) == total + violated + tier3_unguarded` and every status is one of the documented five, with the vocabulary read from the `ObligationStatus` Literal so a sixth member fails rather than vanishing from the counts — per-kind unit tests (requires / ensures / decreases / nat_sub / call_pre statuses, counterexamples, error codes), content-key stability + same-text-two-sites span disambiguation, session solver reuse, type-error short-circuit, ADT-registry resync between programs; plus the Phase B incremental suite — identical-source full replay, callee-body-edit replays callers while callee-contract-edit invalidates them, span-shift and ADT-edit conservative invalidation, cross-program isolation, timeout-status never cached (monkeypatched solver), FIFO eviction bound; plus the #727 dedup pin — a violating call in a let RHS records exactly one E501 diagnostic and one call_pre obligation; plus the #1208 call-site rendering pin — a PARAMETERISED callee slot substitutes into the E501 message and its fix instead of falling back to the generic wording |
+| `test_obligations.py` | 760 | 1,741 | Reified proof obligations + warm `VerificationSession` (#222 Phase A): full-corpus differential oracle (warm session == cold `verify()` on diagnostics, summary, and obligation stream, plus warm-twice determinism, across all 42 examples and every verify/run-level conformance program), summary↔obligation tier-bookkeeping consistency (including the #967 `total == tier1_verified + tier3_runtime` leg, plus a focused self-consistency pin on the three call-demotion examples), the #1242 stream partition — over a corpus widened to every conformance program that type-checks, at any level, `len(obligations) == total + violated + tier3_unguarded` and every status is one of the documented five, with the vocabulary read from the `ObligationStatus` Literal so a sixth member fails rather than vanishing from the counts — per-kind unit tests (requires / ensures / decreases / nat_sub / call_pre statuses, counterexamples, error codes), content-key stability + same-text-two-sites span disambiguation, session solver reuse, type-error short-circuit, ADT-registry resync between programs; plus the Phase B incremental suite — identical-source full replay, callee-body-edit replays callers while callee-contract-edit invalidates them, span-shift and ADT-edit conservative invalidation, cross-program isolation, timeout-status never cached (monkeypatched solver), FIFO eviction bound; plus the #727 dedup pin — a violating call in a let RHS records exactly one E501 diagnostic and one call_pre obligation; plus the #1208 call-site rendering pin — a PARAMETERISED callee slot substitutes into the E501 message and its fix instead of falling back to the generic wording |
 | `test_verifier_contracts.py` | 95 | 898 | Z3 verification over the example corpus, trivial/ensures/if-else/let/multi-clause contracts, counterexamples, tier classification, arithmetic, verification summaries, Diverge effect, edge cases, string-length + string-predicate verification (#839 split) |
 | `test_verifier_nat_obligations.py` | 82 | 1,743 | **`@Nat` subtraction underflow obligation** (#520 — Path-A discharge via requires/path-conditions/path-aware Z3 refutation, pure-literal exclusion, Int-Int and Nat-Int exemptions) and **`@Nat` binding-site narrowing obligation** (#552/#747/#749 — Tier-1 `value >= 0` at let/call-arg/effect-op-arg/ctor-field/match-bind/destructure narrowing — a concrete site classifies `tier3_runtime` (codegen-guarded) while the effect-op argument and generic-instantiated constructor field classify `E504` (obligated but unguarded, #754/#757) whose rationale names its actual cause — an untranslatable value — rather than the untranslatable-or-timeout conflation #1251 removed, walker-recursion pins, `_narrows_into_nat` verifier/codegen soundness parity; PR #972 clone-instantiated side-table substitution — a `Some(@T)` bind in an `Option`-instantiated clone is no narrowing, genuine clone-path narrowings still obligated); #1201 — a builtin `Tuple` parameter's match-bound components carry their declared component facts (a valid ensures over one proves instead of falsely violating) and an `Int` component bound as `@Nat` fires one loud `E503` per component, both mutation-caught (#839 split) |
 | `test_verifier_primitive_ops.py` | 39 | 662 | **Primitive-operation safety obligations** (#680) — division/modulo by-zero `E526` and array-index-bounds `E527`, the in-bounds/out-of-bounds two-check with float-exemption, honest Tier-3 for opaque lengths, off-by-one and lower-bound pins, De Bruijn-correct fix hints (#839 split) |
@@ -122,7 +122,7 @@ python scripts/check_wheel_availability.py           # pre-flight: every runtime
 | `test_soundness_392.py` | 36 | 584 | #392 audit batches 1–2 — verifier soundness/completeness fixes: signed div/mod truncate toward zero (#799), body `assert(P)` carries a Tier-1 obligation (#800), divisions in contract predicates carry a `div_zero` obligation (#801), and the #804 assume-half of #800's `assert` rule — a prior `assert`/`assume` discharges later obligations (including a later call's precondition) + the postcondition at Tier 1, removing false E501/E503/E500/E505 |
 | `test_int_overflow.py` | 6 | 143 | #798 — `@Int`/`@Nat` arithmetic-overflow obligations (part of the #392 `smt.py` soundness audit): `+`/`-`/`*` on `@Int`/`@Nat` now emit an `int_overflow` obligation (the analog of `nat_sub`/`div_zero`) rather than modelling the operands as Z3's unbounded integers, so a `ensures(@Int.result > @Int.0)` over `@Int.0 + 1` no longer proves a contract the i64/u64 runtime violates under two's-complement wraparound. Unbounded operands leave the obligation undischarged (Tier-3, runtime-guarded); operand bounds that prove the result stays in range discharge it at Tier 1 |
 | `test_int_overflow_codegen.py` | 62 | 718 | #798 Stage 3 — runtime overflow-trap codegen: the codegen emits a guard at *exactly* the `@Int`/`@Nat` `+`/`-`/`*` sites the verifier obligates, so `vera run`/`vera compile` programs trap on overflow instead of silently wrapping at the i64/u64 boundary. #808 wired the guard to the `vera.overflow_trap` host import, so the trap now classifies `kind="overflow"` (carrying the overflow Fix paragraph) rather than the generic `unreachable`; `TestOverflowTrapKind808` pins that, with controls proving the #520 `nat_sub` underflow and #813 `@Nat`→`@Int` widen guards still classify `unreachable` |
-| `test_int_overflow_differential.py` | 253 | 398 | #798 Stage 3 verifier↔codegen classification differential (cross-component soundness rule): the codegen overflow guard must fire at exactly the sites the verifier obligates *and* classify each site's operand type (`@Int` i64 vs `@Nat` u64) identically — else a Tier-1-clean program traps spuriously or a wrapping op slips through unguarded. Over a corpus exercising all five operand combos plus the literal-left ambiguity (a naive codegen mis-classifies it as `@Nat`), asserts the verifier's per-site gated classification equals the codegen's site for site, both sides driven by the same `ast.span_key` |
+| `test_int_overflow_differential.py` | 254 | 398 | #798 Stage 3 verifier↔codegen classification differential (cross-component soundness rule): the codegen overflow guard must fire at exactly the sites the verifier obligates *and* classify each site's operand type (`@Int` i64 vs `@Nat` u64) identically — else a Tier-1-clean program traps spuriously or a wrapping op slips through unguarded. Over a corpus exercising all five operand combos plus the literal-left ambiguity (a naive codegen mis-classifies it as `@Nat`), asserts the verifier's per-site gated classification equals the codegen's site for site, both sides driven by the same `ast.span_key` |
 | `test_nat_int_widening.py` | 36 | 602 | #813 — `@Nat -> @Int` widening coercion obligation (dual of #552 `nat_bind`, part of the #392 soundness audit): a `@Nat` in (i64.MAX, u64.MAX] reinterprets when widened (u64.MAX → -1), so a `nat_to_int_coerce` obligation that the value is `<= i64.MAX` now fires at the return position — provably-in-range → Tier-1, provably-out-of-range (`@Nat.0 >= 2**63`) → loud E530, unbounded → honest Tier-3 (runtime-guarded), with an `@Int -> @Int` control that must not fire; the unguarded generic-`@Int`-field case also has its `E531` rationale read for WHAT IT SAYS — a value bounded on neither side, not the untranslatable-or-timeout conflation #1251 removed. The #813 follow-up adds the explicit `nat_to_int` built-in and heterogeneous `if`/`match` arms with a non-negative-literal alternative; #820 adds the heterogeneous-`@Int`-slot arm, closure argument, and closure return/capture obligations (each per-arm / per-site, with `@Int`-arm and `@Nat`-formal controls that must not fire) |
 | `test_int_widening_codegen.py` | 52 | 535 | #813 Stage 3 — runtime `@Nat -> @Int` widening-trap codegen: the codegen emits a guard at *exactly* the `@Nat -> @Int` coercion sites the verifier obligates (return, `let`, call argument, and — since #820 — array element, tuple construction/destructure, heterogeneous `if`/`match` arm, closure argument/return), so `vera run`/`vera compile` programs trap when a `@Nat` above i64.MAX would reinterpret to a negative `@Int` instead of silently returning the wrong value. The trap is a bare `unreachable` (shares `_emit_negative_i64_guard` with the #552 nat-bind guard), classified `kind="unreachable"` today (a dedicated widening trap kind is a follow-up) |
 | `test_int_widening_differential.py` | 26 | 320 | #813 verifier↔codegen behavioural differential (cross-component soundness rule): at every `@Nat -> @Int` coercion site the verifier's `nat_to_int_coerce` classification must AGREE with the runtime — a `tier3` (codegen-guarded) site MUST trap on a `@Nat` above i64.MAX (return / `let` / call-arg / constructor field / ADT sub-pattern / match-bind, and the #820 array-element / tuple-component / heterogeneous-arm / closure argument-return sites), while a `tier3_unguarded` (E531) site must NOT trap (the generic-instantiated `@Int`-field coercion codegen cannot guard).  Runs BOTH sides on one corpus so the "runtime-guarded" claim is checked against the actual trap — catching a verifier deferral codegen never guards (unsound silent -1) or a spurious trap |
@@ -165,7 +165,7 @@ python scripts/check_wheel_availability.py           # pre-flight: every runtime
 | `test_codegen_io.py` | 42 | 821 | IO operations (#135: read\_line, read\_file, write\_file, args, exit, get\_env, sleep, time, stderr), Markdown + Regex host bindings (#419 split) |
 | `test_codegen_collections.py` | 69 | 1,141 | Map + Set collections (#62), wrapper-handle bit-31 tagging (#578) (#419 split) |
 | `test_codegen_json.py` | 116 | 1,112 | Json collection, typed accessors (#419 split), canonical serialization (#1293 — `format_json_number`'s ECMAScript boundaries and `dumps_canonical`'s shape, insertion-ordered keys, non-finite refusal, and rejection of values outside `read_json`'s domain) |
-| `test_json_accept_domain_1306_1308.py` | 97 | 718 | `json_parse`'s accepted domain on the reference host (#1306, #1308): spec §9.7.1 states it as RFC 8259-valid text that decodes to finite numbers and strings of Unicode scalar values, and all three exclusions are pinned end-to-end through a compiled program that reports which arm it took plus the whole `Err` message — the JavaScript constants at the top level and nested in containers, with the first of two naming the refusal; a number that OVERFLOWS to an infinity (`1e999`, `-1e999`, `[1e999]`, `{"a":1e309}`, `1E999`, `[[1e999]]`), the second entry route to a non-finite `JNumber` and the one both host parsers accepted, so it diverged from the stated domain on both hosts at once rather than between them; the same overflow in its INTEGER spelling (`1` followed by 309, 310 and 400 zeros, signed and nested), which was reference-host-only because `json.loads` returns an `int` there and a float-only range check never saw it, with the bound pinned as the double ROUNDING boundary against `float()` as oracle and `int(sys.float_info.max) + 1` as the control that separates it from the obvious-but-wrong bound; and a lone surrogate parameterised over position (value, key, array element, nested, top-level string) and escape casing.  The controls carry the weight the refusals cannot: matched surrogate pairs (single, adjacent, at end of string, literal astral) still parse, `1e308` / `-1e308` / the largest representable double still parse so the overflow refusal is a boundary and not a wall, **underflow is decided rather than assumed** (`1e-999` decodes to `0`, finite and in the domain, so it is accepted — the plausible wrong answer is symmetry with overflow), `"NaN"` as a string value and as a key is ordinary JSON, and text malformed for any other reason keeps its host-native syntax message — the constant-lookalike shapes (`[Infinity_x]`, `{Infinity:1}`) that a raise-on-sight `parse_constant` hook would have misreported, which is why the hook records and the refusal is decided after the parse, and the sign/case shapes (`-NaN`, `[-NaN]`, `+Infinity`, `infinity`, `nan`, `-Infinityx`) that a token scan without a value-start constraint would have claimed.  Unit tests on `first_domain_violation` — the ONE document-order walk both value-level exclusions share, so "whichever comes first names the refusal" needs no precedence table — pin the traversal directly (key before its own value, earlier entry before later, D800/DFFF inclusive at both ends, an `int` and a `bool` never read as a non-finite number, and the NaN arm that no JSON text can reach) where the end-to-end probe can only observe the first refusal.  The cross-host half is `TestBrowserJsonAcceptDomainParity1306_1308` in `test_browser.py` |
+| `test_json_accept_domain_1306_1308.py` | 97 | 720 | `json_parse`'s accepted domain on the reference host (#1306, #1308): spec §9.7.1 states it as RFC 8259-valid text that decodes to finite numbers and strings of Unicode scalar values, and all three exclusions are pinned end-to-end through a compiled program that reports which arm it took plus the whole `Err` message — the JavaScript constants at the top level and nested in containers, with the first of two naming the refusal; a number that OVERFLOWS to an infinity (`1e999`, `-1e999`, `[1e999]`, `{"a":1e309}`, `1E999`, `[[1e999]]`), the second entry route to a non-finite `JNumber` and the one both host parsers accepted, so it diverged from the stated domain on both hosts at once rather than between them; the same overflow in its INTEGER spelling (`1` followed by 309, 310 and 400 zeros, signed and nested), which was reference-host-only because `json.loads` returns an `int` there and a float-only range check never saw it, with the bound pinned as the double ROUNDING boundary against `float()` as oracle and `int(sys.float_info.max) + 1` as the control that separates it from the obvious-but-wrong bound; and a lone surrogate parameterised over position (value, key, array element, nested, top-level string) and escape casing.  The controls carry the weight the refusals cannot: matched surrogate pairs (single, adjacent, at end of string, literal astral) still parse, `1e308` / `-1e308` / the largest representable double still parse so the overflow refusal is a boundary and not a wall, **underflow is decided rather than assumed** (`1e-999` decodes to `0`, finite and in the domain, so it is accepted — the plausible wrong answer is symmetry with overflow), `"NaN"` as a string value and as a key is ordinary JSON, and text malformed for any other reason keeps its host-native syntax message — the constant-lookalike shapes (`[Infinity_x]`, `{Infinity:1}`) that a raise-on-sight `parse_constant` hook would have misreported, which is why the hook records and the refusal is decided after the parse, and the sign/case shapes (`-NaN`, `[-NaN]`, `+Infinity`, `infinity`, `nan`, `-Infinityx`) that a token scan without a value-start constraint would have claimed.  Unit tests on `first_domain_violation` — the ONE document-order walk both value-level exclusions share, so "whichever comes first names the refusal" needs no precedence table — pin the traversal directly (key before its own value, earlier entry before later, D800/DFFF inclusive at both ends, an `int` and a `bool` never read as a non-finite number, and the NaN arm that no JSON text can reach) where the end-to-end probe can only observe the first refusal.  The cross-host half is `TestBrowserJsonAcceptDomainParity1306_1308` in `test_browser.py` |
 | `test_codegen_decimal.py` | 57 | 779 | Decimal collection, Decimal monomorphization (#419 split) |
 | `test_codegen_host_effects.py` | 71 | 1,123 | Html/Http/Inference host effects, provider dispatch, postcondition host-import propagation (#823) (#419 split) |
 | `test_codegen_nat_guards.py` | 61 | 1,435 | **`@Nat` runtime guards**: subtraction underflow (#520) and binding-site narrowing (#552 let site; #747 tuple-destructure / match-bind / ADT sub-pattern / ctor-field / call-arg sites; #758 per-leaf function-return guards incl. type-alias returns and TCO preservation on mixed-arm tails — `i64.lt_s; unreachable` net, `@Int` targets exempt) (#419 split); #758 `@Int -> @Nat` return-position guard; #983 review adds alias-aware return gates (`type Count = Nat` narrow, `type MyInt = Int` widen), the alias-to-refinement single-guard exclusion, and the per-narrowing-leaf emission that keeps a non-narrowing `@Nat -> @Nat` recursive tail call's `return_call` (TCO) intact.  #1256 extends both alias-aware gates to a parameterised alias APPLICATION (`type Ident = T; type Count = Ident;`), which the name-only chase resolved to the bare head `Ident` — so neither gate fired and `f(0 - 5)` returned -5 through the `@Nat` slot, #983's silent negative one spelling over.  Each parameterised case carries its unparameterised twin as the oracle (the claim is that the two spellings compile to the SAME guard, which a bare presence assertion would not catch losing), plus the run-trap on the violating value, the pass on a satisfying one, and the refinement-over-application control that pins the `_refinement_guard_parts` conjunct still keeps a refined return single-guarded |
@@ -189,7 +189,7 @@ python scripts/check_wheel_availability.py           # pre-flight: every runtime
 | `test_string_length_soundness.py` | 15 | 278 | #802 — string_length code-point vs UTF-8 byte soundness: a non-literal `string_length` defers to Tier 3 (the issue's `"é"` probe no longer proves `== 1` at Tier 1), a string-literal length is modeled at its exact UTF-8 byte count (`== 2` for `"é"`), and the boolean predicates `string_contains` / `string_starts_with` / `string_ends_with` stay Tier 1 (sound under UTF-8 self-synchronization), while a predicate over an astral (> U+2FFFF) or lone-surrogate literal defers to Tier 3 (z3.StringVal cannot model those code points) |
 | `test_errors.py` | 62 | 657 | Error code registry, diagnostic formatting, serialisation, SourceLocation, and error display sync — the canonical `E001` diagnostic must match each of its mirrors: `README.md`, `docs/index.html`, `spec/00-introduction.md`, `AGENTS.md`'s example `--json` block, and the hardcoded example in `scripts/build_site.py` that generates `docs/index.md` (#829; `AGENTS.md`'s ellipsis-truncated description/rationale are prefix-compared, its `error_code`/`spec_ref`/`fix` exactly) |
 | `test_eq_contract_874.py` | 13 | 430 | `eq`/`compare` ability ops in contract position: codegen canonicalization + verifier Tier-1 discharge/counterexample, where-fn contracts, compare Ordering-sort materialization, shadowing guard (#874) |
-| `test_formatter.py` | 533 | 3,638 | Comment extraction, interior comment positioning, expression/declaration formatting, match arm block bodies, §1.8 rule 2 in value position (a `let`-bound `match`/`if` expands exactly as one in statement position, and a comment above an arm inside a statement's value stays on that arm), blank-line preservation (§1.8 rule 13 — gaps between statements, before a block result and around a comment, collapsed to one and never invented), idempotency, parenthesization, spec rules, ability declarations |
+| `test_formatter.py` | 534 | 3,638 | Comment extraction, interior comment positioning, expression/declaration formatting, match arm block bodies, §1.8 rule 2 in value position (a `let`-bound `match`/`if` expands exactly as one in statement position, and a comment above an arm inside a statement's value stays on that arm), blank-line preservation (§1.8 rule 13 — gaps between statements, before a block result and around a comment, collapsed to one and never invented), idempotency, parenthesization, spec rules, ability declarations |
 | `test_cli.py` | 273 | 4,612 | CLI commands (check, verify, compile, run, serve, test, fmt, version, quiet), subprocess integration, JSON error paths (including the `verify --json` `obligations` array and its summary-reproducibility pin, #967, and the #1242 partition pin — the array is emitted unfiltered, a refuted obligation is counted by no summary field, and it still joins its E500 on the location key), runtime traps, arg validation, multi-file resolution, IO exit codes, --explain-slots (including the #1208 naming pins — an alias in type-argument position is tabled resolved, and a `forall` variable shadowing a module alias keeps the two parameter stacks apart — and the #1217 `where`-helper tables: the helper prints indented under its parent, appears in the JSON qualified as `parent.helper`, and inherits the enclosing `forall` variables so the shadowing holds inside it too), `builtins`/`effects`/`errors` introspection dispatch, and a USAGE-completeness guard (every dispatched `cmd_` handler has a help row) |
 | `test_introspect.py` | 39 | 221 | `vera builtins/effects/errors --json` registry introspection (#539): the `{schema, items}` envelope, count-equals-registry differential per registry, error-phase derivation, effect/ability `kind` tagging, the parameterised `Exn` effect, and best-effort `since` attribution with full-coverage guards |
 | `test_resolver.py` | 20 | 602 | Module resolution, path lookup, parse caching, circular import detection, the E011/E012/E013 diagnostic contract, internal-error isolation (a compiler bug is not masked as E013), and the transitive-closure return of `resolve_imports` (#890 — a diamond yields each reachable module once, direct imports tagged `direct`, the transitive one not) |
@@ -206,8 +206,8 @@ python scripts/check_wheel_availability.py           # pre-flight: every runtime
 | `test_tester_coverage.py` | 49 | 1,395 | Tester coverage gaps: String/Float64/ADT parameter input generation, Bool/Byte parameters, unsatisfiable preconditions, type expression edge cases, FP model-value extraction (NaN/Inf/signed-zero, #797), the #1208 naming pin (the threaded alias environment canonicalizes a type-argument alias), and the #1216 resolution set — an alias-typed parameter is trialed rather than skipped, its alias-spelled `requires` constrains the generated inputs, a refined alias reaches Z3 with its predicate so no trial violates codegen's entry guard, a `forall` variable shadowing a same-named module alias still resolves to an unsupported type variable, and a parameter that resolves to a non-encodable type still skips with that type named; plus the #1229 set — an UNTRANSLATABLE input constraint skips the function naming the blocking conjunct instead of scoring the resulting entry-guard trap as a contract failure, on the reported `string_length` repro, on a mixed clause (only the untranslatable conjunct is named), on a quantified precondition and on a refined parameter's predicate, with a Tier-3 control that must still be tested so a skip taxonomy firing on everything cannot pass |
 | `test_markdown.py` | 94 | 610 | Markdown parser: block/inline parsing, rendering, round-trips, edge cases |
 | `test_lsp.py` | 146 | 2470 | LSP transport + coordinate layer (#222 Phase C) and language features (#222 Phase D): parametrized code-point↔UTF-16 goldens incl. astral-plane fixtures and surrogate-pair snapping, Span (1-based, exclusive-end) and SourceLocation (0-based col) → LSP Range conversions, point→token-range widening, DocumentStore open/change/close + index invalidation, an in-process handler-drive test, and one stdio end-to-end round-trip against the real `vera lsp` subprocess (initialize → didOpen → shutdown → exit) pinning serverInfo + textDocumentSync capabilities; plus the Phase D feature suite — parse-error single-diagnostic path, type-error verification short-circuit, tier=3 in E520 diagnostic data, per-function tier Hint synthesis (and its suppression for functions with violated obligations), smallest-enclosing-span hover, De Bruijn slot goto (most-recent-parameter jump, out-of-range None, off-slot None, and the #1208 keying pins: a parameterised reference resolves, an alias-spelled parameter is reachable from a canonically-spelled reference, and a `forall` variable shadowing a module alias lands on the right parameter), and typed-hole completion (inside/after hole, away-from-hole None); plus the Phase E speculativeEdit suite — identical-text all-unchanged, breaking edit surfaces newly_undischarged (violated nat_sub) with canonical state untouched, strengthening edit surfaces newly_discharged, parse/type errors report ok:false, deleted functions report removed, proof_delta purity; plus the Phase F1 proposeEdit suite — the apply gate (clean and strengthening edits apply, breaking and non-compiling edits refuse), force overriding both gates with the delta still reported, wiring against a structural fake server (apply round-trip with exact full-document replacement range, refuse touches no canonical state, unopened-URI clamp sentinel), and full-document-range goldens (trailing-newline virtual line, UTF-16 end column); plus the Phase F2 strengthenContract suite — splice goldens (first-clause-only replacement with byte-identical remainder, ensures variant, unknown-fn None), the call-site audit pin (tightened precondition refused with newly_undischarged call_pre items, canonical state untouched), provable-ensures strengthening applies, and the three splice-target refusal paths (no analysis, unparseable document, unknown function); plus the Phase F3 addEffect suite — transitive-caller closure goldens (diamond in declaration order, leaf, unknown-fn None, recursion appears once), handler bounding (#725: a caller discharging the effect around its only call site drops out of the closure and is not rewritten, while a second unhandled path, a call in a handler clause, a handler for another effect, a handler naming a different instance of the same effect (`handle[State]` against a `State` propagation, which the checker does not discharge — end-to-end that caller must still be rewritten and the candidate must still apply, with the matching `State` propagation against the same fixture as the positive control that this handler key does prune something), and a bare `where`-helper call all keep it in, as does a refinement type argument at either depth (`Exn<{ @Int \| p }>` and `Exn>` render as their bare base type but discharge nothing of `Exn` — also pinned end-to-end), while an unparameterised `handle[IO]` does bound an `IO` propagation, a whitespace-spelled `State< Int >` request still bounds, and nesting bounds in either order (a matching handler inside a foreign one, a foreign one inside a matching one) — plus a key-level pin that `handle[Mod.IO]` keeps its module, and the effect-less query pinned handler-unaware; plus the two boundary pins the review added — a call in the handler's STATE INITIALISER keeps its edge, since the initialiser is evaluated in the enclosing scope before the handler is installed (pinned beside the E125 the checker raises there against a `pure` caller, with the identical call in the handler body clean as the contrast), and an alias-spelled handler does not bound a `State` propagation though the checker discharges it (`handle[State]` with `type MyAlias = Int` — the spelling comparison's under-prune, #1292, with the alias-spelled request as the control that does prune)), effect-row rewrite goldens (pure to singleton set, source-preserving append, already-present None, base-name identity blocking State next to State), diamond propagation applying one multi-site candidate with the bystander untouched, mixed append/replace rows with already-satisfied callers skipped, the fully-satisfied no-op shape, and the two refusal paths; plus the #728 instruction-contract suite — the LSP message carries description, rationale, and the Fix: paragraph (also pinning single E501 emission at the LSP surface), and a bare diagnostic maps to the description alone |
-| `test_browser.py` | 347 | 4,733 | Browser parity: Python/wasmtime vs Node.js/JS-runtime output equivalence across IO, State, contracts, Markdown, Regex, and the examples the browser target can execute (two explicit lists in the file, not the whole `examples/` directory — interactive stdin, file IO, `DB` and the non-standalone `modules` example are excluded with their reasons recorded); plus the #349 `runtime.mjs` coverage battery — per-value-type and per-key-type Map variants, per-element-type Set variants, cold `Decimal` branches (exact-zero sign, negative-shift division, `decRoundPlaces` special cases, non-finite storage), `readJson`/`json_stringify` across every Json ADT tag, the Regex/Json `Result.Err` arms, and nested-Markdown walks.  Two operations carry a canonical form the specification states rather than merely agreeing across the hosts, so their batteries assert more than equality: `json_stringify` (spec §9.7.1) pins the expected string on every Json ADT tag and on the number-rendering boundaries, checks three-pass idempotence, checks that a non-finite `JNumber` fails on both hosts *and* prints nothing, pins the object key orders an ordinary JS object cannot carry (array-index keys, which ES enumeration hoists to the front in ascending numeric order, and a `__proto__` key, whose assignment writes a prototype instead of a field) on both a parsed and a program-built object, and checks the reference host's own number rendering differentially against a real `JSON.stringify` over doubles drawn from raw bit patterns; `md_render` (§9.7.3) pins the expected render, re-renders it to prove the fixed point, runs the round-trip property over a corpus carrying the container and multi-line shapes a flat corpus misses, and renders `MdBlock` values the test builds directly, since several renderer rules — a container's child separator, an empty container, a code span wider than one backtick — are unreachable through `md_parse`.  plus the #1306/#1308 accept-domain battery (`TestBrowserJsonAcceptDomainParity1306_1308`), which runs one `.wasm` under both runtimes and compares the WHOLE stdout — arm taken and `Err` message together — over the JavaScript constants, over numbers that overflow to an infinity in both their exponent and integer spellings, and over lone surrogates at every position a string can occupy, with the expected sentences imported from `vera/wasm/json_serde.py` so `runtime.mjs`'s hand-copied duplicates are held against the originals, beside acceptance controls (matched pairs, finite boundary values, underflow to `0`, `"NaN"` as a string value), a ten-case host-native-message battery pinning that neither the substitute-and-re-parse probe nor the value-start constraint hijacks an unrelated syntax error (`-NaN` is the case needing both rules — the substitution alone turns it into `-0` and manufactures a refusal the reference host never makes), a precedence case fixing that a non-finite constant outranks a lone surrogate on both hosts though each reaches that answer by a different route, and a document-order case fixing that overflow and lone surrogate are resolved by one walk rather than by two per-host precedence rules that would agree on every single-violation document; `md_parse` itself is not yet at parity and its remaining divergence classes are a tracked bug ([#1301](https://github.com/aallan/vera/issues/1301)); the suite pins the shapes the two implementations agree on |
-| `test_conformance.py` | 1190 | 144 | Parametrized conformance suite: parse, check, verify, run, format idempotency across 238 programs; a negative entry fails at the stage `expected_error_stage` names (`check`, the default, or `compile` — which also asserts the program type-checks cleanly first) |
+| `test_browser.py` | 413 | 5,094 | Browser parity: Python/wasmtime vs Node.js/JS-runtime output equivalence across IO, State, contracts, Markdown, Regex, and the examples the browser target can execute (two explicit lists in the file, not the whole `examples/` directory — interactive stdin, file IO, `DB` and the non-standalone `modules` example are excluded with their reasons recorded); plus the #349 `runtime.mjs` coverage battery — per-value-type and per-key-type Map variants, per-element-type Set variants, cold `Decimal` branches (exact-zero sign, negative-shift division, `decRoundPlaces` special cases, non-finite storage), `readJson`/`json_stringify` across every Json ADT tag, the Regex/Json `Result.Err` arms, and nested-Markdown walks.  Two operations carry a canonical form the specification states rather than merely agreeing across the hosts, so their batteries assert more than equality: `json_stringify` (spec §9.7.1) pins the expected string on every Json ADT tag and on the number-rendering boundaries, checks three-pass idempotence, checks that a non-finite `JNumber` fails on both hosts *and* prints nothing, pins the object key orders an ordinary JS object cannot carry (array-index keys, which ES enumeration hoists to the front in ascending numeric order, and a `__proto__` key, whose assignment writes a prototype instead of a field) on both a parsed and a program-built object, and checks the reference host's own number rendering differentially against a real `JSON.stringify` over doubles drawn from raw bit patterns; `md_render` (§9.7.3) pins the expected render, re-renders it to prove the fixed point, runs the round-trip property over a corpus carrying the container and multi-line shapes a flat corpus misses, and renders `MdBlock` values the test builds directly, since several renderer rules — a container's child separator, an empty container, a code span wider than one backtick — are unreachable through `md_parse`.  plus the #1306/#1308 accept-domain battery (`TestBrowserJsonAcceptDomainParity1306_1308`), which runs one `.wasm` under both runtimes and compares the WHOLE stdout — arm taken and `Err` message together — over the JavaScript constants, over numbers that overflow to an infinity in both their exponent and integer spellings, and over lone surrogates at every position a string can occupy, with the expected sentences imported from `vera/wasm/json_serde.py` so `runtime.mjs`'s hand-copied duplicates are held against the originals, beside acceptance controls (matched pairs, finite boundary values, underflow to `0`, `"NaN"` as a string value), a ten-case host-native-message battery pinning that neither the substitute-and-re-parse probe nor the value-start constraint hijacks an unrelated syntax error (`-NaN` is the case needing both rules — the substitution alone turns it into `-0` and manufactures a refusal the reference host never makes), a precedence case fixing that a non-finite constant outranks a lone surrogate on both hosts though each reaches that answer by a different route, and a document-order case fixing that overflow and lone surrogate are resolved by one walk rather than by two per-host precedence rules that would agree on every single-violation document; `md_parse` itself is not yet at parity and its remaining divergence classes are a tracked bug ([#1301](https://github.com/aallan/vera/issues/1301)); the suite pins the shapes the two implementations agree on |
+| `test_conformance.py` | 1195 | 144 | Parametrized conformance suite: parse, check, verify, run, format idempotency across 239 programs; a negative entry fails at the stage `expected_error_stage` names (`check`, the default, or `compile` — which also asserts the program type-checks cleanly first) |
 | `test_prelude.py` | 29 | 585 | Prelude injection: Option/Result/array operation detection, combinator shadowing, type aliases, the reserved namespace every injected alias declaration lives in — checked against the checker's own E154 regex rather than a second spelling of the rule, since an alias the prelude injects outside it is one codegen resolves and the checker leaves opaque (#1184/#1221) — end-to-end compilation |
 | `test_checker_apply_fn.py` | 18 | 455 | #854 — `apply_fn` as a checker special form: zero-warning pins (API + CLI `--json` + closures.vera), E201 arity / E202 type / non-function-first-arg errors, E122/E125 effect-row enforcement for applied fn values, E151 redefinition rejection, variadic two-param application, prelude combinator regression pins |
 | `test_prelude_diagnostics.py` | 8 | 271 | #851 — prelude combinator skip-warnings: unreferenced-prelude E602/E604 suppression (zero-warning minimal compile, API + CLI `--json`), `` origin attribution for referenced-but-skipped combinators (text + `to_dict`), transitive reference scan, and user-fn warning locations pinned unchanged |
@@ -229,11 +229,11 @@ python scripts/check_wheel_availability.py           # pre-flight: every runtime
 | `test_doc_builtin_shadowing.py` | 8 | 107 | `check_doc_builtin_shadowing.py` gate ([#819](https://github.com/aallan/vera/issues/819)): reject-set membership (opaque built-ins in, overridable combinators out), top-level + `where`-block `fn ` definitions flagged, non-built-in / overridable / prose-mention ignored, and the shipped docs are currently clean |
 | `test_runtime_traps.py` | 98 | 3,255 | Runtime trap categorisation (#516 Stage 1), out-of-bounds host-read bounds check (#1145), stdout/stderr-on-trap preservation (#522), `IO.print` live tee (#543), and trap source backtrace (#516 Stage 2): `_classify_trap` per-`kind` mapping (`divide_by_zero`/`out_of_bounds`/`stack_exhausted`/`unreachable`/`overflow`/`contract_violation`/`unknown`), plus `host_error` from `_classify_host_error` on `execute()`'s non-`Trap` branch, `WasmTrapError` shape + `RuntimeError` substitutability, end-to-end `cmd_run` text + JSON envelopes including `trap_kind`, captured `stdout`, captured `stderr`, JSON-mode "no stderr leak" invariant, cross-stream code-order regression using merged `redirect_stdout`/`redirect_stderr`, the v0.0.123 tee suite (live streaming, write-count + order preservation, JSON-mode tee suppression, trap preservation invariant under tee, per-write flush count, default-execute silence), and the v0.0.124 source-mapping suite — `_resolve_trap_frames` unit tests covering user-fn / built-in / built-in-prefix / monomorphized base-name fallback / unknown-name / no-frames-attribute / leaf-first ordering preservation; end-to-end `cmd_run` text-mode + JSON-mode backtrace including the **leaf-first** ordering invariant; contract-violation backtrace in both text and JSON modes; direct `execute()` `WasmTrapError.frames` attachment; **suppression marker** for collapsed leading runtime-helper frames (mocked `vera.codegen.execute` with synthetic `is_builtin=True` leaf frames so the collapse logic is testable deterministically); source-map population for top-level fns + lifted closures (with span-value assertion against the closure literal's exact line range); and the no-builtin-leakage regression that pins built-in helpers (`alloc` / `gc_collect` / `contract_fail`) NOT being registered in `fn_source_map`; plus the v0.0.125 Stage 3 suite (`#547`) — text-mode `Fix:` block surfacing with position-ordering invariant (Fix appears after the source backtrace), text-mode block suppression for `contract_violation` (no empty header noise), JSON-mode `fix` field always-present (schema stability) including the empty-string case, `_TRAP_FIX_PARAGRAPHS` table-completeness assertion (every kind in the taxonomy has a Fix paragraph entry), and the column-wrap invariant (~76 chars max per line, two-space indent under the `Fix:` heading); plus the UTF-8 hardening suite **`TestHostPrintInvalidUtf8589`** (`#589` / `#592`) — after `#592` centralised the `errors="replace"` invariant into the single `vera.runtime.text.safe_utf8_decode` helper — reached only through a shared `_slice_and_decode` helper (`vera/runtime/heap.py`) that the three WASM-memory string readers (`_read_wasm_string` and `_read_string_export` there, and `vera/wasm/markdown.py::_read_string`) delegate to, with the `host_print` / `host_stderr` / `host_contract_fail` host imports and the String-return extractor in `execute()` routing through those readers rather than decoding inline: one helper unit test pinning the invariant once (invalid bytes → U+FFFD, valid + empty pass through), three wire-real end-to-end tests that drive the **production** readers (`_read_wasm_string` / markdown `_read_string` behind a synthetic-WAT `probe` host import; `_read_string_export` against a real exported memory, also covering its out-of-bounds → `None` pointer-fallback) over a region seeded with invalid UTF-8 — so a strict-decode regression surfaces as a `UnicodeDecodeError` escaping wasmtime's trampoline, and the host imports / extractor are transitively covered — and one synthetic-WAT end-to-end test that imports `vera.print` and calls it with raw invalid UTF-8 bytes to pin the wasmtime-trampoline fact independently (a Python `UnicodeDecodeError` inside a host import escapes as a "python exception" cause iff the host decode is strict); the six pre-`#592` structural source-grep assertions were retired by the centralisation; plus the Ctrl-C-during-host-import suite **`TestHostSleepKeyboardInterrupt`** ([#595](https://github.com/aallan/vera/issues/595) / [#599](https://github.com/aallan/vera/issues/599)) — after the v0.0.160 relocation to a single `except KeyboardInterrupt` handler in `execute()` (enabled by `wasmtime>=45.0.0`'s `except BaseException` trampoline fix): one structural assertion that the four per-host-import `raise _VeraExit(130)` guards are gone and the centralized handler maps to `exit_code=130`, plus four end-to-end tests that compile real Vera programs calling `IO.sleep(...)`, `IO.read_char(())`, a mocked fused `await` ([#841](https://github.com/aallan/vera/issues/841) — `Future.result()` patched to interrupt), and a live in-flight fused `await` (no mocking — `_thread.interrupt_main()` fired only once the server confirms the request arrived, handler then released so the executor teardown has a real worker to wait out; post-[#848](https://github.com/aallan/vera/issues/848) the progress print precedes the `async(...)`, so program order makes its stdout assertion deterministic), raise `KeyboardInterrupt` from inside the blocking call, and assert the program exits with `ExecuteResult.exit_code == 130` (pre-interrupt stdout preserved) instead of a raw Python traceback escaping wasmtime's trampoline; plus the host-callback surface suite **`TestHostCallbackErrorSurface1302`** / **`TestClassifyHostError1302`** ([#1302](https://github.com/aallan/vera/issues/1302)) — `execute()` classified on the exception's TYPE NAME (`Trap` / `WasmtimeError`), so a host import raising an ordinary Python exception skipped the conversion and escaped as a 63-line interpreter traceback with the captured streams dropped, and in `--json` mode with no envelope emitted at all.  The conversion is now keyed on the BOUNDARY (everything escaping the guest invocation), and the suite drives a real `json_stringify(JNumber(nan()))` program through all three surfaces: `execute()` raising a `WasmTrapError` of `kind="host_error"` carrying the host's sentence, the pre-failure stdout and the original exception as `__cause__`; text-mode `cmd_run` asserted on the ABSENCE of `Traceback` / `File "` / `wasmtime` and a sub-ten-line diagnostic, since asserting only that the sentence appears would still pass on the pre-fix output where it was the traceback's last line; and JSON-mode `cmd_run` producing a parseable envelope with `trap_kind`, an always-present empty `fix`, `frames`, and the captured `stdout`.  **`TestHostErrorDebugKnob1302`** covers the escape hatch the conversion needs — `VERA_DEBUG_HOST_ERRORS` (ENVIRONMENT.md) re-raises the original exception so a binding bug stays diagnosable — as a deliberate pair, one test proving the knob does something and one proving its absence is what produces the one-liner, since neither alone distinguishes a working knob from unconditional behaviour, plus the truthiness table shared with `VERA_EAGER_GC` and an end-to-end `cmd_run` case |
 | `test_serve.py` | 8 | 189 | #305 `vera serve` driver end-to-end: GET/POST echo round-trips (method/path/headers/body cross the host↔guest boundary via `build_request_adt` / `decode_response_adt`), handler status propagation, runtime contract violation → 500 with `trap_kind` JSON, `State` isolation across requests (instance-per-request pinned), and clean `make_server` validation errors (missing / wrong-signature `handle`), and an eager-GC round-trip pinning the Request builder's shadow-rooting; all on ephemeral ports |
-| `test_wasi_target.py` | 270 | 2,142 | #237 WASI Preview 2 target (spec chapter 13): component emission validated live against the real wasmtime host — parse (`Component(engine, wat)`), instantiate (`Linker.add_wasip2()` + `WasiConfig`), and execute (stdout/stderr capture, env, argv incl. a 500-arg GC-pressure stress and a >64 KiB arena-cap trap, preopen file round-trips + errno mapping, stdin incl. UTF-8 multibyte, clocks, random bounds, exit, contract-violation text on WASI stderr, overflow); the family gate (clean diagnostic naming unsupported families, never a silent fallback); the core-emission pin (default `--target wasm` WAT untouched); `cmd_compile`/`cmd_run --target wasi-p2` CLI integration (binary component artifact, `--wat` component text, JSON envelopes, trap-kind classification through the component boundary, exit-code 0/1 degradation, `--fn` rejection); the `execute_wasi_p2` host runner (env passthrough, argv, stderr capture, String-main `wasi:cli/run` fallback); the **dual-target conformance differential** (all 169 run-level conformance programs driven under both targets, byte-identical stdout/stderr required — 119 are dual-tested and 50 skip *loudly* rather than passing silently: 43 whose compiled WAT imports a host family outside `IO`/`Random` (`state`, `map`, `json`, `set`, `decimal`, `html`, `md`, `regex`, `db`), 6 with no public zero-argument `main`, and 1 calling a nondeterministic op.  The excluded set is defined by those three properties rather than by a filename list, so it stays accurate as programs are added); a stock-`wasmtime`-CLI smoke test (skips when the CLI is not installed); and the Stage-D **server world** (`world="server"`): incoming-handler emission pins (adapter lift, 32-slot dispatch table, @0.2.0 version pin, no `wasi:cli/run`), #305 handler validation + server family-gate diagnostics (rejected IO ops, non-String map instantiations, unsupported families), the cli-world pin (default emission carries no server machinery), Request/Response layout tripwires, and a stock-`wasmtime serve` smoke battery (host-vs-served differential over a method/path/header/body matrix incl. duplicate-header later-wins, in-guest map-op order parity, `IO.print` console routing, trap→500 with symbolized backtrace + violation text, graceful 500s for forbidden headers and out-of-range status, a 1 MiB GC-stress echo, and an eager-GC shadow-push mutation validation; skips when the CLI is not installed) |
+| `test_wasi_target.py` | 271 | 2,142 | #237 WASI Preview 2 target (spec chapter 13): component emission validated live against the real wasmtime host — parse (`Component(engine, wat)`), instantiate (`Linker.add_wasip2()` + `WasiConfig`), and execute (stdout/stderr capture, env, argv incl. a 500-arg GC-pressure stress and a >64 KiB arena-cap trap, preopen file round-trips + errno mapping, stdin incl. UTF-8 multibyte, clocks, random bounds, exit, contract-violation text on WASI stderr, overflow); the family gate (clean diagnostic naming unsupported families, never a silent fallback); the core-emission pin (default `--target wasm` WAT untouched); `cmd_compile`/`cmd_run --target wasi-p2` CLI integration (binary component artifact, `--wat` component text, JSON envelopes, trap-kind classification through the component boundary, exit-code 0/1 degradation, `--fn` rejection); the `execute_wasi_p2` host runner (env passthrough, argv, stderr capture, String-main `wasi:cli/run` fallback); the **dual-target conformance differential** (all 169 run-level conformance programs driven under both targets, byte-identical stdout/stderr required — 119 are dual-tested and 50 skip *loudly* rather than passing silently: 43 whose compiled WAT imports a host family outside `IO`/`Random` (`state`, `map`, `json`, `set`, `decimal`, `html`, `md`, `regex`, `db`), 6 with no public zero-argument `main`, and 1 calling a nondeterministic op.  The excluded set is defined by those three properties rather than by a filename list, so it stays accurate as programs are added); a stock-`wasmtime`-CLI smoke test (skips when the CLI is not installed); and the Stage-D **server world** (`world="server"`): incoming-handler emission pins (adapter lift, 32-slot dispatch table, @0.2.0 version pin, no `wasi:cli/run`), #305 handler validation + server family-gate diagnostics (rejected IO ops, non-String map instantiations, unsupported families), the cli-world pin (default emission carries no server machinery), Request/Response layout tripwires, and a stock-`wasmtime serve` smoke battery (host-vs-served differential over a method/path/header/body matrix incl. duplicate-header later-wins, in-guest map-op order parity, `IO.print` console routing, trap→500 with symbolized backtrace + violation text, graceful 500s for forbidden headers and out-of-range status, a 1 MiB GC-stress echo, and an eager-GC shadow-push mutation validation; skips when the CLI is not installed) |
 
 ## Conformance Suite
 
-The conformance suite is a collection of 238 small, focused programs in `tests/conformance/` that systematically validate every language feature against the spec. Most programs are self-contained; the module-focused Chapter 8 cases use `import` statements where needed, and `ch07_cross_module_contracts.vera` still depends on `ch07_cross_module_contracts_lib.vera`. Each program tests one feature or a small group of related features.
+The conformance suite is a collection of 239 small, focused programs in `tests/conformance/` that systematically validate every language feature against the spec. Most programs are self-contained; the module-focused Chapter 8 cases use `import` statements where needed, and `ch07_cross_module_contracts.vera` still depends on `ch07_cross_module_contracts_lib.vera`. Each program tests one feature or a small group of related features.
 
 Simon Willison [argues](https://simonwillison.net/tags/conformance-suites/) that conformance suites are a "huge unlock" for language projects — they transform development from trust-based to verification-based. The conformance suite serves as the definitive specification artifact that any implementation (or agent) can validate against.
 
@@ -260,7 +260,7 @@ Each conformance program declares the deepest pipeline stage it must pass:
 | `parse` | Source text is syntactically valid | 0 |
 | `check` | Parses and type-checks cleanly | 49 |
 | `verify` | Type-checks and all contracts verified by Z3 | 20 |
-| `run` | Compiles to WASM and executes correctly | 169 |
+| `run` | Compiles to WASM and executes correctly | 170 |
 
 Almost all programs are at the `run` level — they compile and execute, producing correct results. Forty-nine programs (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch03_typed_holes`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_cross_module_contracts_lib`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch07_bare_effect_op_rejected`, `ch08_ambiguous_import_adt_lib_bool`, `ch08_ambiguous_import_adt_lib_int`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_lib_bool`, `ch08_ambiguous_import_lib_int`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_cross_module_generic_lib`, `ch08_module_generic_diamond_base`, `ch08_module_prelude_adt_contention_rejected`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_transitive_module_import_base`, `ch08_visibility_private`, `ch08_xmod_widen_lib`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_eq_non_derivable_rejected`, `ch09_http`, `ch09_inference`, `ch09_ord_adt_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) are at the `check` level. Thirty-seven of them — `ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch07_bare_effect_op_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, and `ch02_alias_cycle_rejected` — are **negative tests** that assert a specific diagnostic (E206, E135, E183, E201, E127, E153, E153, E153, E130, E130, E174, E182, E217, E011, E154, E154, E154, E154, E154, E154, E150, E152, E151, E242, E243, E207, E208, E208, E209, E128, E336, and E132 respectively) via the manifest's `expected_error` field.  One more — `ch08_module_prelude_adt_contention_rejected` — is a negative at the `compile` stage rather than at `check`: it carries `expected_error_stage: "compile"` beside `expected_error: E621`, so the harness asserts it type-checks CLEANLY and is then refused by `vera compile` with that code, which is the property a codegen-phase diagnostic exists for.  `ch09_http` and `ch09_inference` are environment-gated (network / API key). Twenty programs (`ch03_slot_let_chains`, `ch03_slot_noncommutative`, `ch04_nested_option_ctor`, `ch04_primitive_obligations`, `ch05_apply_fn_typing`, `ch06_adt_sort_disambiguation`, `ch07_cross_module_contracts`, `ch07_invisible_import_op_name_lib`, `ch07_io_read_char`, `ch07_io_sleep`, `ch07_random_effect`, `ch08_state_alias_module_table_lib`, `ch08_module_generic_diamond_mid1`, `ch08_module_generic_diamond_mid2`, `ch08_state_alias_per_module_lib`, `ch08_transitive_module_import_mid`, `ch09_http_server`, `ch09_invisible_import_ability_op_lib`, `ch09_math_builtins`, `ch09_nested_helper_family_op_name_lib`) are at the `verify` level, using Z3-provable contracts — a library module is pinned at the deepest level it reaches, so the two per-module alias-table libraries are verified rather than only checked.
 
@@ -406,7 +406,7 @@ tests/conformance/
 ├── ch01_int_literals.vera     # Chapter 1: Integer literals
 ├── ch01_float_literals.vera   # Chapter 1: Float64 literals
 ├── ch01_string_escapes.vera   # Chapter 1: String escape sequences
-├── ...                        # 238 programs total, organized by spec chapter
+├── ...                        # 239 programs total, organized by spec chapter
 ├── ch07_state_handler.vera    # Chapter 7: State effect handler
 ├── ch07_exn_handler.vera      # Chapter 7: Exn effect handler
 ├── ch09_numeric_builtins.vera # Chapter 9: Numeric built-in functions
@@ -440,7 +440,7 @@ The manifest is the machine-readable feature inventory — agents can query it t
 ### Running the conformance suite
 
 ```bash
-# Via pytest (parametrized — 1,190 tests: five stages × 238 entries)
+# Via pytest (parametrized — 1,195 tests: five stages × 239 entries)
 pytest tests/test_conformance.py -v
 
 # Via standalone script (used in CI and pre-commit)
@@ -969,9 +969,9 @@ Twenty-nine scripts in `scripts/` validate cross-cutting concerns beyond unit te
 
 | Script | What it validates |
 |--------|-------------------|
-| `check_conformance.py` | All 238 conformance entries hold at their declared level (parse/check/verify/run) — positives pass; the negatives fail at the stage their `expected_error_stage` names (`check` by default, or `compile`, which also asserts the program type-checks cleanly) with their `expected_error` E-code |
+| `check_conformance.py` | All 239 conformance entries hold at their declared level (parse/check/verify/run) — positives pass; the negatives fail at the stage their `expected_error_stage` names (`check` by default, or `compile`, which also asserts the program type-checks cleanly) with their `expected_error` E-code |
 | `check_examples.py` | All 42 `.vera` examples pass `vera check` + `vera verify` |
-| `check_corpus_canonical.py` | All 287 corpus programs (recursive over `examples/` + `tests/conformance/`) are in canonical form under `vera fmt` |
+| `check_corpus_canonical.py` | All 288 corpus programs (recursive over `examples/` + `tests/conformance/`) are in canonical form under `vera fmt` |
 | `check_examples_readme.py` | Every `vera run` command in examples/README.md references an existing file and exported function |
 | `check_spec_examples.py` | 189 parseable code blocks from spec chapters: parse, type-check, and verify |
 | `check_readme_examples.py` | All Vera code blocks in README.md parse correctly |
@@ -1078,9 +1078,9 @@ The repository configures 36 hooks across two stages: 34 run at the commit stage
 | `ruff check .` | Lint Python with ruff (default `F` + `E` rules) |
 | `mypy vera/` | Type-check compiler in strict mode |
 | `pytest tests/ -q` | Run full test suite |
-| `check_conformance.py` | All 238 conformance entries hold at their declared level — positives pass; negatives fail at the stage their `expected_error_stage` names (`check` or `compile`) with their `expected_error` E-code |
+| `check_conformance.py` | All 239 conformance entries hold at their declared level — positives pass; negatives fail at the stage their `expected_error_stage` names (`check` or `compile`) with their `expected_error` E-code |
 | `check_examples.py` | All 42 examples pass `vera check` + `vera verify` |
-| `check_corpus_canonical.py` | All 287 `examples/` + `tests/conformance/` programs (recursive) are in canonical form (`vera fmt`) |
+| `check_corpus_canonical.py` | All 288 `examples/` + `tests/conformance/` programs (recursive) are in canonical form (`vera fmt`) |
 | `check_examples_readme.py` | `vera run` commands in `examples/README.md` reference existing files and exported functions |
 | `check_readme_examples.py` | README code blocks parse correctly |
 | `check_examples_doc.py` | EXAMPLES.md code blocks parse correctly |
diff --git a/docs/SKILL.md b/docs/SKILL.md
index 1a90cb97..e2cf8d7f 100644
--- a/docs/SKILL.md
+++ b/docs/SKILL.md
@@ -2402,7 +2402,7 @@ public fn main(@Unit -> @Unit)
 
 ## Conformance Suite
 
-The `tests/conformance/` directory contains 238 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics.
+The `tests/conformance/` directory contains 239 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics.
 
 Each program is organized by spec chapter (`ch01_int_literals.vera`, `ch04_match_basic.vera`, `ch07_state_handler.vera`, etc.) and the `manifest.json` file maps features to programs. When you need to see how a specific construct works, check the conformance program before reading the spec.
 
diff --git a/docs/index.html b/docs/index.html
index 9e549251..34cca628 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -699,7 +699,7 @@ 

This page is also a machine-readable specificati Vera is under active development

- A complete compiler with 164 built-in functions, ten algebraic effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge), contract-driven testing via Z3, a language server with agent-facing proof deltas, and a 14-chapter specification. A 238-program conformance suite and 42 worked examples are validated against the spec on every pull request. All of it is developed openly on GitHub and released under the MIT licence. + A complete compiler with 164 built-in functions, ten algebraic effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge), contract-driven testing via Z3, a language server with agent-facing proof deltas, and a 14-chapter specification. A 239-program conformance suite and 42 worked examples are validated against the spec on every pull request. All of it is developed openly on GitHub and released under the MIT licence.

diff --git a/docs/index.md b/docs/index.md index 0c86625f..e261e478 100644 --- a/docs/index.md +++ b/docs/index.md @@ -281,7 +281,7 @@ For other models: point them at [`SKILL.md`](https://veralang.dev/SKILL.md) via ## Status -Vera is under [active development](https://raw.githubusercontent.com/aallan/vera/main/ROADMAP.md). A complete compiler with 164 built-in functions, ten algebraic effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge), contract-driven testing via [Z3](https://www.microsoft.com/en-us/research/project/z3-3/), and a 14-chapter specification. A 238-program conformance suite and 42 worked examples are validated against the spec on every pull request. All of it is developed openly on [GitHub](https://github.com/aallan/vera) and released under the MIT licence. +Vera is under [active development](https://raw.githubusercontent.com/aallan/vera/main/ROADMAP.md). A complete compiler with 164 built-in functions, ten algebraic effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge), contract-driven testing via [Z3](https://www.microsoft.com/en-us/research/project/z3-3/), and a 14-chapter specification. A 239-program conformance suite and 42 worked examples are validated against the spec on every pull request. All of it is developed openly on [GitHub](https://github.com/aallan/vera) and released under the MIT licence. ## Links diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 99669e8d..51104aa1 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -2408,7 +2408,7 @@ public fn main(@Unit -> @Unit) ## Conformance Suite -The `tests/conformance/` directory contains 238 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. +The `tests/conformance/` directory contains 239 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. Each program is organized by spec chapter (`ch01_int_literals.vera`, `ch04_match_basic.vera`, `ch07_state_handler.vera`, etc.) and the `manifest.json` file maps features to programs. When you need to see how a specific construct works, check the conformance program before reading the spec. @@ -2487,7 +2487,7 @@ Read `SKILL.md` for the full language reference. It covers syntax, slot referenc ### Conformance programs as reference -The conformance suite in `tests/conformance/` contains 238 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-seven negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) instead must *fail* with the E-code in their `expected_error` field, at the stage their `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. +The conformance suite in `tests/conformance/` contains 239 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-seven negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) instead must *fail* with the E-code in their `expected_error` field, at the stage their `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. ### Workflow @@ -2666,9 +2666,9 @@ Each stage is a module with a single public API function (`parse_file`, `transfo pytest tests/ -v # Run all tests (see TESTING.md) pytest tests/test_conformance.py -v # Conformance suite only mypy vera/ # Type-check the compiler -python scripts/check_conformance.py # All 238 conformance programs hold (positives pass; negatives fail with their E-code) +python scripts/check_conformance.py # All 239 conformance programs hold (positives pass; negatives fail with their E-code) python scripts/check_examples.py # All 42 examples must pass -python scripts/check_corpus_canonical.py # All 287 corpus programs in canonical form +python scripts/check_corpus_canonical.py # All 288 corpus programs in canonical form ``` Test helpers follow a pattern: `_check_ok(source)` / `_check_err(source, match)` / `_verify_ok(source)` / `_verify_err(source, match)`. See existing tests for examples. @@ -2677,7 +2677,7 @@ When implementing a new language feature, write the conformance program *first* ### Invariants -- All 238 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first +- All 239 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first - All 42 examples in `examples/` must pass `vera check` and `vera verify` - `mypy vera/` must be clean - `pytest tests/ -v` must pass @@ -3222,7 +3222,7 @@ None of this is Vera-specific, but it validates the design choices. The thesis i This is a real concern. LLMs are trained on trillions of tokens of Python, TypeScript, and JavaScript. A MojoBench study (NAACL 2025) found that even fine-tuned models achieved only 30–35% improvement over base models on Mojo code generation, illustrating the cold-start problem for new languages. -Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (238 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification. +Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (239 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification. ## How does Vera compare to Dafny / Lean / Koka / F*? @@ -3265,7 +3265,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 11,237 tests, including a 238-program conformance suite +- 11,434 tests, including a 239-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/docs/llms.txt b/docs/llms.txt index c870199e..eb155d32 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -56,4 +56,4 @@ Current version: 0.1.11. The reference compiler is written in Python. Install th - [TESTING.md](https://raw.githubusercontent.com/aallan/vera/main/TESTING.md): Test suite architecture, coverage data, and test conventions. - [KNOWN_ISSUES.md](https://raw.githubusercontent.com/aallan/vera/main/KNOWN_ISSUES.md): Known bugs and limitations. - [CONTRIBUTING.md](https://raw.githubusercontent.com/aallan/vera/main/CONTRIBUTING.md): Contribution guidelines. -- [Conformance Suite](https://github.com/aallan/vera/tree/main/tests/conformance): 238 programs validating every language feature against the spec. +- [Conformance Suite](https://github.com/aallan/vera/tree/main/tests/conformance): 239 programs validating every language feature against the spec. diff --git a/tests/test_json_accept_domain_1306_1308.py b/tests/test_json_accept_domain_1306_1308.py index 59e9991a..faba5f8c 100644 --- a/tests/test_json_accept_domain_1306_1308.py +++ b/tests/test_json_accept_domain_1306_1308.py @@ -581,15 +581,17 @@ def test_message_names_the_code_point_and_the_remedy(self) -> None: assert "json_parse:" in msg assert "pair" in msg - def test_message_normalises_the_hex_casing(self) -> None: - """One sentence per code point, whatever the input looked like. - - The escape can be written ``\\ud800`` or ``\\uD800``; the - refusal names the code point, so both spellings produce the same - message and the parity battery can compare across hosts without - a casing rule. + def test_message_renders_the_code_point_in_uppercase_hex(self) -> None: + """The rendering is uppercase whatever the caller passes. + + That the two *input* spellings ``\\ud800`` and ``\\uD800`` + reach the same sentence is carried end to end by the + ``value_lower`` / ``value_upper`` pairs in + ``_LONE_SURROGATE_CASES``, which parse real text through + ``json_parse``; this function takes a code point, so it cannot + observe the input spelling at all and only the rendering is + left to pin. """ - assert lone_surrogate_message(0xD800) == lone_surrogate_message(0xD800) assert "\\uD800" in lone_surrogate_message(0xD800) assert "\\uDFFF" in lone_surrogate_message(0xDFFF) diff --git a/vera/README.md b/vera/README.md index 41d038ef..4f21c302 100644 --- a/vera/README.md +++ b/vera/README.md @@ -600,7 +600,7 @@ The WASM import interface is the portability contract: the compiled `.wasm` bina **GC reachability discipline (JS host side):** JS host functions that allocate multiple WASM heap blocks and hold intermediates in JS locals must root those intermediates on the shadow stack — otherwise EAGER_GC (and, under pressure, normal GC) reclaims them mid-walk. The runtime exports two helpers: `gcShadowPush(ptr)` writes a pointer to `$gc_sp` and advances it (throws if `$gc_sp` / `$gc_stack_limit` aren't exported, since that means the module was built without GC support but is calling allocators that can trigger GC), and `gcGuard(fn)` saves `$gc_sp` at entry and restores it on exit (success or exception). This is the browser parallel of the CLI-side `_ShadowGuard` context manager added in v0.0.158 (#692). The walkers `writeJson` / `writeHtml` and the parsers `json_parse` / `html_parse` wrap their bodies in `gcGuard` and push intermediates (`arrPtr`, `wrapperPtr`, `jsonPtr`) as soon as each is allocated — see `runtime.mjs` for the canonical pattern. Without this, `Map` / `Set` and similar heap-pointer-keyed collections drop values under GC pressure (#708). -**Parity enforcement:** `tests/test_browser.py` runs the examples the browser target can execute — two explicit lists in that file, not the whole `examples/` directory, since an example that reads stdin interactively, uses a refused host family (file IO, `DB`), or does not compile standalone cannot be compared — plus per-binding batteries over the Map/Set/Decimal/Json/Regex/Markdown host imports, through both Python/wasmtime and Node.js/JS-runtime. The two example lists carry different oracles: the examples exporting `main` are run and compared on stdout, while the ones reached as exported functions are called with fixed arguments and compared on the returned value. The per-binding batteries compare stdout. `json_stringify` and `md_render` are compared the same way and additionally against the canonical form the specification states for each (§9.7.1, §9.7.3), because cross-host equality alone would be satisfied by two hosts agreeing on a wrong answer; `md_render` is also asserted stable under re-render and exercised on `MdBlock` values the test *builds*, since several renderer rules are unreachable through `md_parse`, and `json_stringify`'s number rendering is checked differentially against a real `JSON.stringify`. `json_parse` is compared by accepted domain, the parse-side counterpart: §9.7.1 states the domain — RFC 8259-valid text that decodes to finite numbers and strings of Unicode scalar values — and the battery compares the whole `Err` message across hosts for the JavaScript constants and for a lone-surrogate escape, parameterised over every position a string can occupy (value, key, array element, nested), beside controls the refusals must not disturb: matched surrogate pairs, and `"NaN"` as an ordinary string value. `md_parse` is the one parser still diverging — plain-text run grouping inside a paragraph, and a handful of block markers §9.7.3 does not pin — tracked as [#1301](https://github.com/aallan/vera/issues/1301); for it the suite pins the inputs the two do agree on. The browser stubs are covered on two different shapes: `IO.read_file` and `IO.write_file` get the same per-host pinning, run through both runtimes against a path that really is readable or writable so the native `Ok` and the browser `Err` are each asserted (a missing file or an unwritable directory would `Err` on both sides and prove nothing), while `IO.read_char` is exercised in Node alone — the module links and the stub's `Err` arm returns `0` — with no native run to compare against. `Inference` and `DB` return `Err` from every browser operation, which is a deliberate platform boundary — the credentials they need would be readable from page source — rather than a divergence awaiting a fix. Pre-commit hooks and CI trigger these tests on any change to the host binding surface. +**Parity enforcement:** `tests/test_browser.py` runs the examples the browser target can execute — two explicit lists in that file, not the whole `examples/` directory, since an example that reads stdin interactively, uses a refused host family (file IO, `DB`), or does not compile standalone cannot be compared — plus per-binding batteries over the Map/Set/Decimal/Json/Regex/Markdown host imports, through both Python/wasmtime and Node.js/JS-runtime. The two example lists carry different oracles: the examples exporting `main` are run and compared on stdout, while the ones reached as exported functions are called with fixed arguments and compared on the returned value. The per-binding batteries compare stdout. `json_stringify` and `md_render` are compared the same way and additionally against the canonical form the specification states for each (§9.7.1, §9.7.3), because cross-host equality alone would be satisfied by two hosts agreeing on a wrong answer; `md_render` is also asserted stable under re-render and exercised on `MdBlock` values the test *builds*, since several renderer rules are unreachable through `md_parse`, and `json_stringify`'s number rendering is checked differentially against a real `JSON.stringify`. `json_parse` is compared by accepted domain, the parse-side counterpart: §9.7.1 states the domain — RFC 8259-valid text that decodes to finite numbers and strings of Unicode scalar values — and the battery compares the whole `Err` message across hosts for the JavaScript constants, for a number that overflows to an infinity in either spelling — with an exponent (`1e999`) or as plain digits (`1` followed by 309 zeros, the route `json.loads` decodes to an `int`) — and for a lone-surrogate escape, parameterised over every position a string can occupy (value, key, array element, nested), beside controls the refusals must not disturb: matched surrogate pairs, `"NaN"` as an ordinary string value, the finite boundary values, underflow to `0`, and the band between the largest finite double and the rounding boundary, whose integers are larger than `sys.float_info.max` and still accepted by both hosts. `md_parse` is the one parser still diverging — plain-text run grouping inside a paragraph, and a handful of block markers §9.7.3 does not pin — tracked as [#1301](https://github.com/aallan/vera/issues/1301); for it the suite pins the inputs the two do agree on. The browser stubs are covered on two different shapes: `IO.read_file` and `IO.write_file` get the same per-host pinning, run through both runtimes against a path that really is readable or writable so the native `Ok` and the browser `Err` are each asserted (a missing file or an unwritable directory would `Err` on both sides and prove nothing), while `IO.read_char` is exercised in Node alone — the module links and the stub's `Err` arm returns `0` — with no native run to compare against. `Inference` and `DB` return `Err` from every browser operation, which is a deliberate platform boundary — the credentials they need would be readable from page source — rather than a divergence awaiting a fix. Pre-commit hooks and CI trigger these tests on any change to the host binding surface. `browser/emit.py` provides `emit_browser_bundle()` for the `vera compile --target browser` CLI command, which produces a ready-to-serve directory (module.wasm + vera-runtime.mjs + index.html). @@ -753,7 +753,7 @@ The `ERROR_CODES` dict in `errors.py` maps every code to a short description (16 ## Test Suite -Testing spans a **pytest suite** of 11,237 tests across 170 files — compiler-internals unit tests plus a **conformance suite** (238 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. +Testing spans a **pytest suite** of 11,434 tests across 171 files — compiler-internals unit tests plus a **conformance suite** (239 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. See **[TESTING.md](../TESTING.md)** for the comprehensive testing reference -- test file table, conformance suite details, compiler code coverage, language feature coverage, helper conventions, validation scripts, CI pipeline, and guidelines for adding tests. diff --git a/vera/wasm/json_serde.py b/vera/wasm/json_serde.py index 71d68885..7770978b 100644 --- a/vera/wasm/json_serde.py +++ b/vera/wasm/json_serde.py @@ -154,6 +154,7 @@ def _first_lone_surrogate_in_str(text: str) -> int | None: return code_point return None + def non_finite_number_message(name: str) -> str: """The single sentence both runtimes return for an overflowing number. From 1f5a23d5c79745e78a84a2523c2ad919a8a5aa17 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 18:18:57 +0100 Subject: [PATCH 43/69] Sync the conformance-negative prose with the manifest Three measured mismatches from CodeRabbit's incremental review, each verified against tests/conformance/manifest.json before editing: the check-stage negative list heads thirty-six names (the compile-stage E621 negative is the separately-described thirty-seventh) but said thirty-seven; its code enumeration omitted the four ambiguous-import codes at their fixture positions (E156, E156, E155, E155); the skip split sentence still read 98 from before those fixtures landed while the tables carry 114 + 4 = 118 rows; and the adt_swapped_rejected row cited E157 where the manifest declares E156. Co-Authored-By: Claude --- TESTING.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/TESTING.md b/TESTING.md index be39ed26..683ae64b 100644 --- a/TESTING.md +++ b/TESTING.md @@ -262,11 +262,11 @@ Each conformance program declares the deepest pipeline stage it must pass: | `verify` | Type-checks and all contracts verified by Z3 | 20 | | `run` | Compiles to WASM and executes correctly | 170 | -Almost all programs are at the `run` level — they compile and execute, producing correct results. Forty-nine programs (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch03_typed_holes`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_cross_module_contracts_lib`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch07_bare_effect_op_rejected`, `ch08_ambiguous_import_adt_lib_bool`, `ch08_ambiguous_import_adt_lib_int`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_lib_bool`, `ch08_ambiguous_import_lib_int`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_cross_module_generic_lib`, `ch08_module_generic_diamond_base`, `ch08_module_prelude_adt_contention_rejected`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_transitive_module_import_base`, `ch08_visibility_private`, `ch08_xmod_widen_lib`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_eq_non_derivable_rejected`, `ch09_http`, `ch09_inference`, `ch09_ord_adt_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) are at the `check` level. Thirty-seven of them — `ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch07_bare_effect_op_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, and `ch02_alias_cycle_rejected` — are **negative tests** that assert a specific diagnostic (E206, E135, E183, E201, E127, E153, E153, E153, E130, E130, E174, E182, E217, E011, E154, E154, E154, E154, E154, E154, E150, E152, E151, E242, E243, E207, E208, E208, E209, E128, E336, and E132 respectively) via the manifest's `expected_error` field. One more — `ch08_module_prelude_adt_contention_rejected` — is a negative at the `compile` stage rather than at `check`: it carries `expected_error_stage: "compile"` beside `expected_error: E621`, so the harness asserts it type-checks CLEANLY and is then refused by `vera compile` with that code, which is the property a codegen-phase diagnostic exists for. `ch09_http` and `ch09_inference` are environment-gated (network / API key). Twenty programs (`ch03_slot_let_chains`, `ch03_slot_noncommutative`, `ch04_nested_option_ctor`, `ch04_primitive_obligations`, `ch05_apply_fn_typing`, `ch06_adt_sort_disambiguation`, `ch07_cross_module_contracts`, `ch07_invisible_import_op_name_lib`, `ch07_io_read_char`, `ch07_io_sleep`, `ch07_random_effect`, `ch08_state_alias_module_table_lib`, `ch08_module_generic_diamond_mid1`, `ch08_module_generic_diamond_mid2`, `ch08_state_alias_per_module_lib`, `ch08_transitive_module_import_mid`, `ch09_http_server`, `ch09_invisible_import_ability_op_lib`, `ch09_math_builtins`, `ch09_nested_helper_family_op_name_lib`) are at the `verify` level, using Z3-provable contracts — a library module is pinned at the deepest level it reaches, so the two per-module alias-table libraries are verified rather than only checked. +Almost all programs are at the `run` level — they compile and execute, producing correct results. Forty-nine programs (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch03_typed_holes`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_cross_module_contracts_lib`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch07_bare_effect_op_rejected`, `ch08_ambiguous_import_adt_lib_bool`, `ch08_ambiguous_import_adt_lib_int`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_lib_bool`, `ch08_ambiguous_import_lib_int`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_cross_module_generic_lib`, `ch08_module_generic_diamond_base`, `ch08_module_prelude_adt_contention_rejected`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_transitive_module_import_base`, `ch08_visibility_private`, `ch08_xmod_widen_lib`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_eq_non_derivable_rejected`, `ch09_http`, `ch09_inference`, `ch09_ord_adt_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) are at the `check` level. Thirty-six of them — `ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch07_bare_effect_op_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, and `ch02_alias_cycle_rejected` — are **negative tests** that assert a specific diagnostic (E206, E135, E183, E201, E127, E153, E153, E153, E130, E130, E174, E182, E217, E156, E156, E155, E155, E011, E154, E154, E154, E154, E154, E154, E150, E152, E151, E242, E243, E207, E208, E208, E209, E128, E336, and E132 respectively) via the manifest's `expected_error` field. One more — `ch08_module_prelude_adt_contention_rejected` — is a negative at the `compile` stage rather than at `check`: it carries `expected_error_stage: "compile"` beside `expected_error: E621`, so the harness asserts it type-checks CLEANLY and is then refused by `vera compile` with that code, which is the property a codegen-phase diagnostic exists for. `ch09_http` and `ch09_inference` are environment-gated (network / API key). Twenty programs (`ch03_slot_let_chains`, `ch03_slot_noncommutative`, `ch04_nested_option_ctor`, `ch04_primitive_obligations`, `ch05_apply_fn_typing`, `ch06_adt_sort_disambiguation`, `ch07_cross_module_contracts`, `ch07_invisible_import_op_name_lib`, `ch07_io_read_char`, `ch07_io_sleep`, `ch07_random_effect`, `ch08_state_alias_module_table_lib`, `ch08_module_generic_diamond_mid1`, `ch08_module_generic_diamond_mid2`, `ch08_state_alias_per_module_lib`, `ch08_transitive_module_import_mid`, `ch09_http_server`, `ch09_invisible_import_ability_op_lib`, `ch09_math_builtins`, `ch09_nested_helper_family_op_name_lib`) are at the `verify` level, using Z3-provable contracts — a library module is pinned at the deepest level it reaches, so the two per-module alias-table libraries are verified rather than only checked. ### Skipped tests -`pytest tests/ -v` skips 118 conformance-stage tests, and every one of them is the level rule: a program declared at `check` skips its `verify` and `run` stages, one declared at `verify` skips its `run` — 49 × 2 + 20, which is what the suite reports. The two tables below split those 118 by why the program sits at its level, not by how it skipped: 98 are pinned there by the feature under test, and 4 by an environment CI does not have. Each skip is listed once; the tables do not overlap. (The suite's remaining skips are platform- or tool-gated and documented beside the tests that declare them.) +`pytest tests/ -v` skips 118 conformance-stage tests, and every one of them is the level rule: a program declared at `check` skips its `verify` and `run` stages, one declared at `verify` skips its `run` — 49 × 2 + 20, which is what the suite reports. The two tables below split those 118 by why the program sits at its level, not by how it skipped: 114 are pinned there by the feature under test, and 4 by an environment CI does not have. Each skip is listed once; the tables do not overlap. (The suite's remaining skips are platform- or tool-gated and documented beside the tests that declare them.) **Level-limited skips** — the conformance framework only runs tests up to the declared level; stages beyond that level are automatically skipped. These are expected and correct. @@ -349,7 +349,7 @@ Almost all programs are at the `run` level — they compile and execute, produci | `test_run[ch08_ambiguous_import_adt_lib_bool]` | `ch08_ambiguous_import_adt_lib_bool.vera` | `check` | `run` | `check`-level library module: no standalone `main` | | `test_verify[ch08_ambiguous_import_adt_rejected]` | `ch08_ambiguous_import_adt_rejected.vera` | `check` | `verify` | `check`-level negative test (`expected_error: E156`): verify stage not run | | `test_run[ch08_ambiguous_import_adt_rejected]` | `ch08_ambiguous_import_adt_rejected.vera` | `check` | `run` | `check`-level negative test: no `run` stage | -| `test_verify[ch08_ambiguous_import_adt_swapped_rejected]` | `ch08_ambiguous_import_adt_swapped_rejected.vera` | `check` | `verify` | `check`-level negative test (`expected_error: E157`): verify stage not run | +| `test_verify[ch08_ambiguous_import_adt_swapped_rejected]` | `ch08_ambiguous_import_adt_swapped_rejected.vera` | `check` | `verify` | `check`-level negative test (`expected_error: E156`): verify stage not run | | `test_run[ch08_ambiguous_import_adt_swapped_rejected]` | `ch08_ambiguous_import_adt_swapped_rejected.vera` | `check` | `run` | `check`-level negative test: no `run` stage | | `test_verify[ch08_ambiguous_import_lib_int]` | `ch08_ambiguous_import_lib_int.vera` | `check` | `verify` | `check`-level library module: verify stage not run | | `test_run[ch08_ambiguous_import_lib_int]` | `ch08_ambiguous_import_lib_int.vera` | `check` | `run` | `check`-level library module: no standalone `main` | From 74e07abd9828dcaf2c35b3a6a3a5fd3704fd4580 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 13:48:50 +0100 Subject: [PATCH 44/69] Resolve two codegen WASM-type disagreements, one of them silent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codegen derived a slot's WASM width from a branch order that was not the checker's, and it saved a match scrutinee into a local at a type that is not a WAT type. Both produced check-green, verify-green programs that could not run, and the first produced one that ran and computed the wrong answer. `_type_expr_to_wasm_type` tested the registered ADTs — and `Array`, `Map`, `Set` and `Decimal`, none of which are primitives — before the alias table, where `vera/naming.py`'s `_resolve_named` documents the checker's order as primitive, then alias, then declared ADT. Spec 8.4.1 makes the prelude's data types ordinary declarations a program names and shadows, so `type Option = Int;` is legal and its parameter must emit i64; codegen emitted the ADT pointer's i32. The alias branch now sits after the primitives and ahead of every ADT and container branch. The issue predicted that a matching width would make the same disagreement silent. Measured over every built-in ADT name against every representation, matching widths are inert — the emitted WAT is byte-identical to the same program under a fresh alias name. The silent cases are the pair types, where the widths differ and nothing traps: an i32_pair is two words, the single i32 dropped the length word, and `string_concat("ab", "ab")` returned junk bytes while `array_length` over three elements returned 0, both at exit 0. `_translate_match` allocated one local at the scrutinee's inferred WAT type, so a `String` or `Array` scrutinee wrote `(local $l1 i32_pair)` and the module never assembled. Scrutinee and binding pattern now take two consecutive i32 locals, the (ptr, len) convention parameters and constructor fields already use. Which arm patterns may sit over such a scrutinee is a WHITELIST — wildcard and binding, nothing else. It was first written as a blacklist naming the two constructor kinds, and that was worse than the defect it accompanied: a pair has no comparable scalar word either, so a literal arm fell through into the arm-condition emitter and compiled the scrutinee's heap POINTER as the condition. `match @String.0 { true -> 100, _ -> 200 }` went from a loud WAT failure to a check-green program that exits 0 and prints 100, and the integer twin shipped a `.wasm` that died at instantiation carrying no diagnostic at all. Enumerating what IS lowerable cannot fail that way when a pattern kind is added. A pair carries no constructor tag either, so a constructor or nullary arm is refused by the same rule, at the pattern's own location, rather than lowered into a read of the array's first four bytes. Two defects the work surfaced but does not close take Bugs-table rows, alongside three more the adversarial round found: 1316, where a main-file alias named after a prelude ADT leaks into the prelude's own bodies (the alias ENVIRONMENT, a sibling of this branch ORDER fix — the 16-name sweep goes from 15 broken to 2, and the order fix leaves that mechanism untouched while moving where it fails); 1315, where the checker accepts a constructor pattern over a container ADT that has no constructors, which is how the ill-typed program in the second issue reached codegen at all; 1320, the literal-pattern half of that same checker hole; 1321, the container branches sitting ahead of the registered ADT layouts, which misclassifies a user `data Array`; and 1322, a match binding's shadow-stack push never being popped, pre-existing on the ADT path and newly reachable on this one. The 268 pre-existing conformance and example programs are unchanged: check JSON, verify JSON and emitted WAT all compare byte-identical against the branch point, 804 artifacts with no movers. Two conformance programs carry the distinguishing shapes at run level, and the ch09_json entry's spec_ref is corrected from a Section 9.4.4 that does not exist to the 9.7.1 the Json section actually is. Closes #1309 Closes #1305 Co-Authored-By: Claude --- CHANGELOG.md | 5 + KNOWN_ISSUES.md | 7 +- TESTING.md | 2 + .../ch04_match_pair_scrutinee.vera | 44 ++ .../ch08_alias_shadows_prelude_adt_name.vera | 39 + tests/conformance/manifest.json | 41 +- .../test_codegen_alias_adt_name_width_1309.py | 328 +++++++++ tests/test_codegen_pair_scrutinee_1305.py | 685 ++++++++++++++++++ vera/codegen/core.py | 57 +- vera/wasm/data.py | 76 +- 10 files changed, 1259 insertions(+), 25 deletions(-) create mode 100644 tests/conformance/ch04_match_pair_scrutinee.vera create mode 100644 tests/conformance/ch08_alias_shadows_prelude_adt_name.vera create mode 100644 tests/test_codegen_alias_adt_name_width_1309.py create mode 100644 tests/test_codegen_pair_scrutinee_1305.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 01cb90cb..3ae0bff4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **A lone-surrogate escape is refused at the parse, on both runtimes** ([#1308](https://github.com/aallan/vera/issues/1308)). `{"k":"a\ud800b"}` is grammatically legal RFC 8259 whose decoded value is not a sequence of Unicode scalar values, and a Vera `String` is — so the value has no UTF-8 encoding and cannot cross the WASM boundary at all. Both host parsers accepted the text and the *memory boundary* decided what happened next, differently and by accident: the browser's `TextEncoder` substituted U+FFFD, so `json_stringify` printed `{"k":"a�b"}` with nothing to tell the caller the value had changed, while the reference host died inside `_alloc_string` with a raw `UnicodeEncodeError`. Neither is a value the program can handle. The refusal now happens where the decoded value is known and before anything is marshalled, with one sentence naming the code point in canonical `\uXXXX` form so both escape casings produce the same message. Keys are covered as well as values, at any nesting depth — the key position is the one the issue's own reproduction used. The check does not overshoot: a *matched* high-then-low pair denotes one astral scalar value and still parses, which the batteries pin with matched pairs in every position, two pairs adjacent, and a pair at the end of a string. The two hosts' scans differ in a way worth recording, because the same rule reads differently against the two representations of a decoded value — `json.loads` has already combined a well-formed escape pair into one astral code point, so a plain D800–DFFF range test is complete on the reference host, while a JS string is UTF-16 and its scan must consume pairs before judging anything lone. With this and [#1306](https://github.com/aallan/vera/issues/1306), `md_parse` is the only operation on the shared surface still diverging (§12.9.3). - **A host callback's failure is a Vera error, not a Python traceback** ([#1302](https://github.com/aallan/vera/issues/1302)). `execute()` converted an escaping exception into `WasmTrapError` only when its type name was `Trap` or `WasmtimeError`. A host import raising an ordinary Python exception — `json_stringify` refusing a non-finite `JNumber`, the case that surfaced it — is re-raised through wasmtime's trampoline and arrives as, say, a `ValueError`, so the branch was skipped entirely: no classification, no source-map resolution, and the captured stdout/stderr dropped as the exception unwound. Measured on a program printing `"before"` and then `json_stringify(JNumber(nan()))`: **63 lines** of stderr across 17 Python stack frames, none of them naming the user's `.vera` file — and in `--json` mode no envelope at all, so a machine consumer got nothing parseable. It is now one line: `Error: json_stringify: NaN is not representable in JSON — RFC 8259 has no NaN or Infinity. Guard with float_is_nan / float_is_infinite before serialising.` The refusal itself was always right and is an instruction (DESIGN principle 1); only its presentation was wrong. The conversion is keyed on the **boundary** rather than on the exception's type, which is what makes the fix general: the guarded region is the guest invocation and nothing else, so everything arriving there is either a wasmtime trap or a host callback that raised, and every compiler phase has already finished. The taxonomy gains a `host_error` kind, carried in the JSON envelope's `trap_kind` beside the captured `stdout` (per [#522](https://github.com/aallan/vera/issues/522)); its `Fix` paragraph is empty for the same reason `contract_violation`'s is — the description already carries the specific instruction, and a canned paragraph beneath it would be noise. The original exception stays reachable as `__cause__` for anyone debugging the binding itself, and `VERA_DEBUG_HOST_ERRORS=1` re-raises it untouched so the Python frames are still one environment variable away (ENVIRONMENT.md). This closes the gap against the invariant already written on `host_print` in `vera/codegen/api.py`: *a user-level program must never produce a Python traceback regardless of what it does.* +- **A `type` alias sharing a prelude ADT's name emits the alias target's width** ([#1309](https://github.com/aallan/vera/issues/1309)). Spec §8.4.1 makes the prelude's data types ordinary declarations a program names *and shadows*, and the checker resolves such a name the way `vera/naming.py`'s `_resolve_named` documents — type parameter, primitive, alias, declared ADT. Codegen's `_type_expr_to_wasm_type` tested `_adt_layouts` (and `Array` / `Map` / `Set` / `Decimal`, none of which are primitives) *before* the alias table, so `type Option = Int;` emitted its parameter as the ADT's i32 pointer where the checker and verifier had both agreed it was an i64 — check-green, verify-green, dead at load with `type mismatch: expected i64, found i32`. The alias branch now sits where the checker puts it: after the primitives, ahead of every ADT and container branch. The issue predicted the disagreement would be *silently* wrong wherever the two widths coincide; measured across every built-in ADT name against every representation, that is not where the silence is. A matching width (`Bool`, `Byte`, `Map`, `Set`, `Decimal`, all i32) emits WAT byte-identical to the same program under a fresh alias name — inert. The silent cases are the **pair** types, whose widths differ: an `i32_pair` is two words, the ADT branch's single i32 dropped the length, and nothing trapped — `type Option = String;` returned two junk bytes for `string_concat("ab", "ab")` and `type Option = Array;` reported length 0 for a three-element array, both at exit 0. Of the 16 built-in ADT and container names, 14 now compile and run correctly where 1 did before. `Json` and `HtmlNode` still fail, in a *prelude* body (`json_get`, `html_attr`) rather than the user's, because those bodies render their own parameters against the flat alias map a main-file shadow pollutes — an alias-env scoping defect ([#1316](https://github.com/aallan/vera/issues/1316)) this branch-order fix does not reach. It does, however, MOVE that failure: the reorder flips 17 prelude `json_*` signatures from `(param $p0 i32)` to `(param $p0 i64)`, reverses the mismatch the loader reports (`expected i64, found i32` becomes `expected i32, found i64`), shifts its offset, and costs `html_attr` one shadow-stack push. Same defect, same frame, later point — not, as an earlier draft of this entry claimed, an identical failure. A generated battery pins every name in the live built-in ADT registry against every representation class, comparing the emitted `twice` signature to a fresh-name control, so a reintroduced ordering cannot hide behind matching widths. + +- **A `match` on a `String` or `Array` scrutinee compiles** ([#1305](https://github.com/aallan/vera/issues/1305)). `_translate_match` saved the scrutinee into one local at its inferred WAT type, and a pair-represented scrutinee infers `i32_pair` — the internal two-word spelling, not a value type — so the module carried `(local $l1 i32_pair)` and never assembled. The scrutinee and any binding pattern over it now take two consecutive i32 locals, the same (ptr, len) convention parameters and constructor fields already use. Programs as ordinary as `match @String.0 { @String -> string_length(@String.0) }` and `match @Array.0 { @Array -> array_length(@Array.0) }` were check-green and failed `vera compile` at the branch point; both run now. The issue reached this through `json_keys` and framed it as an `Option>` payload binder, which measurement does not support: `json_keys` returns `Array` (see `vera/environment.py` and the prelude body), so its result was never a binder problem and `array_length(json_keys(j))` compiled and ran throughout — the trigger is the scrutinee's representation, with nothing JSON-specific about it. The issue's own repro additionally matches `Some` / `None` against that array; a pair carries no constructor tag, so codegen now refuses that arm with an `E602` naming it, at the pattern's own location, instead of emitting a local that stops the whole module from assembling. That the checker accepts such a match over a constructor-less container ADT at all is filed separately as [#1315](https://github.com/aallan/vera/issues/1315). + +- **The `ch09_json` conformance entry cites the section that exists.** Its manifest `spec_ref` read `Section 9.4.4`, a section Chapter 9 does not have — the chapter's `9.4` runs to `9.4.3 Map` and `Json` is `9.7.1`. - **A user-defined `fn get` / `fn put` is no longer hijacked by an enclosing handler** ([#1284](https://github.com/aallan/vera/issues/1284)). Three sites answered "does this `get` mean the user's declaration or the effect operation?" independently. The checker answers user-fn-first — `_check_call_with_args` looks a bare name up as a function before it looks it up as an operation, so a declaration named `get` owns every bare `get(...)` in its scope, which an arity or argument-type error at such a call site proves by reporting the *user's* signature (E201/E202). Codegen answered twice more: the declared-effect row in `vera/codegen/functions.py` withheld the intrinsic when `_fn_sigs` already owned the name, and the handler expression in `vera/wasm/calls_handlers.py` installed `get`/`put` unconditionally. From `vera check`-green source that produced, depending on the nesting shape, a **silently wrong value** (`nat_to_int(get(3))` under `handle[State](@Int = 5)` returned the cell's 5 for the function's 4, and the argument was not even emitted), a **module WASM validation rejects** (a `@Bool`-returning user `get` took `state_get_Int`'s i64 into an `i32` position; different-family nesting took the *enclosing* cell's getter at the wrong width), or a **spurious `[E602]`** in which the #1233 unaddressable-cell gate refused `main` outright, naming "a bare or qualified State operation `get`" the program never contained. The repair is one predicate, `vera.slots.bare_call_denotes_user_fn`, stating the checker's rule once and consumed by the bare-call dispatch in `vera/wasm/calls.py` (which now gates the clause-inline registry, the host-cell intrinsics and the addressability gate together), by the three bare-`FnCall` result-type inference sites, and by the monomorphizer's discovery walk — each passing its own name table, so the sites cannot answer differently about the table they share. Gating the *dispatch* rather than the *registries* is what makes it correct rather than merely consistent: the registries record which cell an op name reaches, which is true whatever the program's declarations are called, and withholding an entry answered both questions with one table. That is why the gate-only fix measured during PR #1283's review turned the loud skip into a differently-broken module, and why it also cost the qualified spelling its cell — `State.put(5)` in a function that also declares `fn put` compiled to `call $vera.put` and failed to link, which now lowers to the intrinsic the checker always meant. Discovery's `MonoContext.fn_names` moves to the same lookup-time question, so a `get(())` fixing a generic's type variable under a handler names the clone the rewrite emits. All 256 conformance and example programs emit byte-identical WAT. **The `W002` async-commutativity warning was the same defect in the checker's own file** and is corrected with them: `_collect_expr_effects` asked `lookup_effect_op` before the scoped function lookup — the last op-first consumer — so a user function named after an operation contributed the *operation's* parent effect to the commutativity analysis instead of its own declared row, wrong in both directions. A **pure** `fn get`, in a program containing no `State` at all, drew `async argument performs State effects`; a `fn get` that performs `IO`, under a row naming `Http` first, drew **no warning**, because the walk bound the name to `Http.get`, which is inside the commutative whitelist, and silently withheld the eager-evaluation warning the program is owed. Both are pinned with rename controls — the byte-identical program with the helper called `gett` / `fetch` was correct throughout — beside a control that an unshadowed bare `get(())` under a `State` row still warns, so the fix cannot degenerate into never reporting `State`. The walk's comment claiming it resolves "like the call checker above" is now true. One caveat the predicate did not close on its own: codegen's name table was not scope-accurate, so a name the call site cannot see still answered "user-owned" there — a property of the table rather than of the rule, closed by [#1299](https://github.com/aallan/vera/issues/1299) below. diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 285ba87c..45ce79f8 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -8,12 +8,15 @@ Defects in shipped compiler, runtime, or tooling behaviour — this table matche | Bug | Issue | |-----|-------| +| A match binding's GC shadow-stack push is never popped, so the shadow stack grows with recursion depth until it traps. Both `_translate_match`'s scrutinee rooting and `_setup_match_arm_env`'s binding rooting push onto `$gc_sp` and leave it advanced for the rest of the body; a function restores `$gc_sp` only on the way out, so a recursive function whose body matches pushes once per live frame and eventually crosses `$gc_stack_limit`, whose overflow check is a bare `unreachable`. Bisected on a `decreases`-guarded recursion carrying one match: an ADT (i32) scrutinee survives depth 2046 and traps at **2047**, **identically before and after [#1305](https://github.com/aallan/vera/issues/1305)**, which is what makes this pre-existing rather than new — the i32 rooting has behaved this way since #705/#707. What #1305 changed is reach: a `String` / `Array` scrutinee now takes this path too, and because the pair form roots twice per frame (scrutinee pointer and binder pointer) it survives only 1364 and traps at **1365** — two thirds of the ADT depth, which is what the slot arithmetic predicts: an ADT frame roots the parameter and the binder, a pair frame roots the parameter, the scrutinee pointer and the binder pointer, so three slots per frame against two and 2047 x 2/3 = 1364.7. Loud in every case — `unreachable`, never a wrong value — but the trap names no source construct and the depth at which it arrives is an artefact of how many pushes a body happens to make. Fix direction: pop what a match arm pushes at the arm's end (or scope the rooting to the arm body), so the depth a program can recurse to stops depending on how many matches its body contains. | [#1322](https://github.com/aallan/vera/issues/1322) | +| Codegen's built-in-container branches are tested before `_adt_layouts`, so a user `data` declaration named after a container is classified by the container's representation instead of its own. `_type_expr_to_wasm_type` answers `Array` with `i32_pair` before it ever consults the registered ADT layouts, so `private data Array { MkArr(Int) }` — a perfectly ordinary declaration spec §8.4.1 permits — has its parameter classified as a two-word pair rather than the one-word heap pointer its constructor actually builds. `Map`, `Set`, `Decimal`, `Tuple` and `Future` are inert instances of the same ordering (their branch answers `i32`, which is what the ADT branch would have answered anyway) and compile and run correctly; `Array` is the one name whose two answers differ, and it fails on both sides of [#1309](https://github.com/aallan/vera/issues/1309) — as an unassemblable `i32_pair` local before it, and as an `[E602]` skip after. That E602 currently misattributes the cause, since the message it inherits from the [#1305](https://github.com/aallan/vera/issues/1305) pair guard describes the scrutinee's representation rather than the declaration that produced it; the wording no longer names `String` / `Array` as the source types, but a reader still has to know that a user `data Array` is why a pair representation was claimed at all. Sibling of #1309 (which reordered the alias branch ahead of these same container branches) and of [#1316](https://github.com/aallan/vera/issues/1316) (which is the environment those branches are consulted in) — one function, three orthogonal questions. Fix direction: consult `_adt_layouts` before the built-in container branches, matching `_resolve_named`, where a DECLARED ADT precedes the built-in absorption. | [#1321](https://github.com/aallan/vera/issues/1321) | +| The checker admits literal patterns over scrutinees that can never match them, which is the general disease behind the container-ADT case in [#1315](https://github.com/aallan/vera/issues/1315). `match { true -> 100, _ -> 200 }` and its `1 ->` integer twin are **check-green** — no diagnostic of any kind — although a `String` can equal neither a boolean nor an integer. Nothing downstream is obliged to catch this: codegen refuses the pair-represented spellings since [#1305](https://github.com/aallan/vera/issues/1305) only because a pair has no comparable scalar word, which is an accident of representation rather than a rule about patterns, and the same nonsense over a scrutinee that IS one word has no such backstop. The two holes are one question asked twice — #1315 is "does this constructor belong to this scrutinee's type", this is "does this literal belong to it" — and both fall out of the checker validating a pattern against the arm's own shape rather than against the scrutinee's type. Fix direction: type each pattern against the scrutinee, once, for every pattern kind; the exhaustiveness pass is where the constructor half already half-lives, but the literal half has no home at all today. | [#1320](https://github.com/aallan/vera/issues/1320) | | `E609` refuses two modules' same-named `data` declarations, and `E610` two modules' same-named **constructors**, by DECLARATION — consulting neither visibility nor the importing namespace's import filter nor local shadowing — the over-breadth [#1281](https://github.com/aallan/vera/issues/1281) removed from the function-side twin `E608`, still in place on the data side. Three remedies were measured against a diamond where `liba` and `libb` both export `public data Shape`, and all three fail identically: **narrowing** the second import so it no longer supplies the type, **declaring** `Shape` in the importing module (which spec §8.5.2 makes shadow both imports), and marking one module's declaration **`private`** (which exports nothing at all) each leave `vera check` and `vera verify` green and then die at `vera compile` with `[E609]` located at line 0 of the entry file, naming modules the entry never imports. `E610` behaves identically on its own axis, and its shape shows the collision is not about the type name at all: `liba` exporting `public data Alpha { Sq(Int), … }` beside `libb` exporting `public data Beta { Sq(Bool), … }` — two DIFFERENT types sharing only the constructor `Sq` — is `[E610]` at compile, and the same three remedies fail it in the same way (narrow the second import, declare a local `data Own { Sq(Int), … }`, or make one supplier `private`: each is check-green and verify-green and each is still `[E610]` at `vera compile`). Renaming in one of the source modules is therefore the only remedy for either code, which is why [#1304](https://github.com/aallan/vera/issues/1304)'s `E156`/`E157` prescribe renaming where their function-side sibling `E155` can offer a selective import or a local declaration — and why spec §8.5.4's "constructor names follow the same shadowing rules as function names" holds of resolution but not of compilation. Sibling of [#1312](https://github.com/aallan/vera/issues/1312): both are the flat namespace's data-collision rails mishandling declarations that occupy distinct identities. Fix direction: give `E609`/`E610` the #1281 treatment — read the same per-owner classification the clone namespace already does, so a declaration no importing namespace can name does not collide with one it can. | [#1317](https://github.com/aallan/vera/issues/1317) | +| A main-file `type` alias named after a prelude ADT leaks into the PRELUDE's own bodies. Codegen's `_type_expr_to_wasm_type` resolves an alias against the flat `self._type_aliases` map (`vera/codegen/core.py`), which holds every namespace's aliases at once, so a prelude combinator emitted into the module renders its own parameters through the main file's shadow: under `type Json = Int;` the prelude's `json_get` takes the alias's i64 where its body wants the ADT's i32 pointer, and the module dies at load (`type mismatch: expected i32, found i64` in `wasm[0]::function[10]::json_get`) on a check-green, verify-green program. `type HtmlNode = Int;` is the same failure in `html_attr`. Spec §8.4.1 makes the alias namespace module-scoped and `vera/naming.py` states the consequence — every consumer must render against the env of the module that DECLARED the enclosing function — but this derivation has no module-scoped env; `_prelude_type_aliases` is already populated in Pass 1.2 and is what a scoped rendering would read. Sibling of [#1309](https://github.com/aallan/vera/issues/1309) but not the same defect: that one was the branch ORDER inside this function (ADT before alias, now corrected to the checker's primitive-alias-ADT spine); this is which alias ENVIRONMENT the function is asked in, and the order fix leaves that mechanism untouched. It does MOVE the failure downstream within prelude scope, though — the reorder flips 17 prelude `json_*` signatures from `(param $p0 i32)` to `(param $p0 i64)`, reverses the loader's complaint from `expected i64, found i32` to `expected i32, found i64`, shifts its offset, and costs `html_attr` one shadow-stack push — so a repro captured before #1309 will not match byte for byte after it. Measured by sweeping `type X = Int;` over all 16 built-in ADT and container names: **15 of 16 broken before #1309, 2 of 16 after** — and both survivors fail in a prelude body rather than the user's, at the branch point and after, which is what separates the two defects. A user ADT that merely SHARES a prelude name is the membership question [#1277](https://github.com/aallan/vera/issues/1277) closed and [#1312](https://github.com/aallan/vera/issues/1312) continues, not this one. Fix direction: give the derivation the declaring module's env, as the checker, verifier and monomorphizer already receive. | [#1316](https://github.com/aallan/vera/issues/1316) | +| The checker accepts a constructor pattern over a container ADT that has no constructors. `match >` and `match >` with `Some(...)` / `None` arms check **completely clean** — `ok: true`, zero diagnostics, zero warnings — because the exhaustiveness pass reads `self.env.data_types.get(raw_ty.name)` and returns early on the miss (`vera/checker/control.py`, `return # unknown ADT, can't check`), and no pattern-type rule objects either. The same nonsense over a primitive scrutinee IS refused: an `@Int` or `@String` scrutinee reaches the infinite-domain branch and reports **E313**, so the hole is exactly the built-in containers, which are `AdtType`s the constructor registry does not know. This is how the ill-typed program in [#1305](https://github.com/aallan/vera/issues/1305) reached codegen at all — its `Some(@Array)` arm over `json_keys`' `Array`, which the issue mistook for an `Option` payload; since that fix, codegen refuses the arm with a located **E602** naming the pair scrutinee's absent tag rather than emitting an unassemblable local, so for a PAIR-represented scrutinee the shape is loud, never a wrong answer, and merely reported against the compiler's back stop instead of against the program. That loudness does not extend to the rest of the hole: a `Map` or `Set` scrutinee is an opaque i32 handle, which the pair guard cannot see and codegen happily emits a tag read over — `match map_new() { Some(@Int) -> 1, None -> 2 }` exits 0 printing 2, identically before and after that fix. So the wrong answers this hole admits are live, and only the pair corner of it is currently caught. Fix direction: rule on constructor patterns whose scrutinee names no constructor registry — a checker change, whose new rejections need their own blast-radius pass, and which would both close the handle-scrutinee case and make the E602 back stop unreachable from source. | [#1315](https://github.com/aallan/vera/issues/1315) | | An entry-file `data` declaration and a module's of the same name silently drop the caller when their shapes differ. `private data Json { JMine(Int) }` in the entry beside `private data Json { JBlob(Int) }` in an imported module is `vera check`-green and then compiles with **exit 0**, `ok: true`, and nothing but `[E602]`/`[E620]` **warnings** — and the entry's `main`, which calls the module's function, is absent from the exports (measured: `exports == ['consume']`, the one function that touches only the entry's own declaration). The artifact loads and simply lacks its entry point. The flat namespace holds one layout per name and the entry's declaration takes it — Pass 1 registers the main file's `data` over the Pass-0.5 module harvest, which only `setdefault`s — so the module's own constructors become `unknown constructor` inside its own bodies. Same one-layout-per-name defect as [#1277](https://github.com/aallan/vera/issues/1277) and **not** closed by its E621 rail, for a structural reason rather than an oversight: that rail compares a PRELUDE declaration against a module's, and an entry-file declaration *suppresses* the prelude's injection outright, so no prelude declaration ever reaches Pass 1.2 and the contending pair here — entry versus module — is one the rail cannot be asked about. Behaviourally identical at `8e08ec90` and after #1277's fix, so pre-existing rather than introduced. Fix direction: put the entry-versus-module pair through the same contention check, reusing the machinery #1277 installed — `vera/prelude.py`'s `data_decl_shape` already decides whether two declarations of one name can share a layout (it is what keeps a module's restatement of a prelude type legal), and the conformance manifest's `expected_error_stage: "compile"` already expresses a check-green/compile-refused negative — leaving the ruling: a new code, or E609 widened from module-versus-module to take the entry file as a namespace, located at both declarations. | [#1312](https://github.com/aallan/vera/issues/1312) | | A module-declared generic instantiated at a type that comes from an EFFECT-OPERATION result never registers its clone. Codegen reports a compilation note (`call target 'mod$$$' not registered`) and silently drops the calling function, so the emitted module has no `main` — from a program `vera check` and `vera verify` both pass. Two conditions, bisected and both necessary: the generic is declared in a **module** (so its clone is the `mod$`-mangled form) and its type argument is inferred from an effect operation's result. Neither nesting under a `where` helper nor `private` visibility is needed — `public` fails identically. Controls that pass: a literal type argument, a type argument from a plain module-function result, and the identical shape with the generic declared in the entry file. This is the `mod$`-mangled REGISTRATION path for module generics (the #1274/#1281 machinery), distinct from #1299's scope-table divergence — measured identical on `release/v0.1.12`'s base and on the #1299/#1281 branch, so that fix neither causes it nor changes it. | [#1310](https://github.com/aallan/vera/issues/1310) | -| A `type` alias whose name is also a registered ADT's compiles at the ADT's **pointer width**, not the alias body's. `type Option = Int;` with a `@Option` parameter is `vera check`-green and `vera verify`-green (4 Tier 1) and then dies at `vera run` with `Invalid input WebAssembly code at offset 79: type mismatch: expected i64, found i32`: codegen emits `(param $p0 i32)`, the ADT's heap pointer, where the checker resolved the alias body and typed the slot `Int`. Single file, no modules, and the byte-identical program with the alias renamed `Widget` emits `(param $p0 i64)` and prints 42 — so it is the collision with a registered ADT name, not aliases in general, and it is reachable for every name in `_adt_layouts`: the built-ins `_register_builtin_adts` installs as much as the prelude's. **Loud only by luck of the widths.** `type Ordering = Bool` runs and answers correctly because the alias body and the ADT pointer are both i32, so at matching widths the same resolution disagreement raises no WebAssembly error at all and the body reads a pointer as its aliased value — a silent wrong answer from a check-green, verify-green program is the class this row tracks, and the i64/i32 case above is merely the arm that happens to be caught by validation. Spec §8.4.1 permits the declaration — the prelude's and the built-in data types are ordinary declarations a program may name and shadow, the reserved namespace being the `Vera` prefix alone (E154) — so the fix is to make codegen agree with the checker rather than to reject the program. `vera/naming.py`'s `_resolve_named` already states the branch order (type parameter → primitive → alias, arity-checked → declared ADT); fix direction: route the WASM-width derivation through that one resolver instead of consulting `_adt_layouts` ahead of the alias maps. | [#1309](https://github.com/aallan/vera/issues/1309) | | The checker resolves a bare call to a SIBLING function's `where` helper. Spec §5 makes a helper local to its parent, and the checker's own `_lookup_function_scoped` implements that — it walks the enclosing frame stack and reads each frame's direct helpers — but it then falls back to `env.lookup_function`, and `vera/registration.py` has recursed every helper into that flat `TypeEnv`. So a top-level `other()` calling `helperx(7)`, where `helperx` is a helper of an unrelated `holder`, is **check-green and verify-green** and then refused by codegen (`Function 'helperx' is not defined in this module and was not found in any imported module`) — the helper is emitted as `holder$where$helperx`, so the bare call has no target. Loud, never a wrong answer. The op-name variant is the one that matters for the #1284 ownership predicate: with the helper named `get` and the sibling reading a `State` cell by bare `get(())`, the checker binds the HELPER and reports `[E202] Argument 0 of 'get' has type Unit, expected Int` where spec §7.4 resolves the operation — so the checker rejects a program codegen compiles correctly, the two tables disagreeing in the CHECKER's direction for the first time. Codegen's `_scoped_fns` (#1299) implements the spec rule; the fix here is a checker change, whose new rejections need their own blast-radius pass. | [#1307](https://github.com/aallan/vera/issues/1307) | -| `json_keys`' result cannot be used from compiled Vera. The `Some` arm of its `Option>` binds a match binder of type `Array`, and codegen emits that binder's local as `(local $lN i32_pair)` — `i32_pair` being the internal String `(ptr, len)` spelling rather than a WASM value type — so the module fails WAT assembly with `unexpected token, expected one of: i32, i64, …`. A ten-line `public fn key_count(@Json -> @Int)` matching `json_keys` and calling `array_length` on the binder passes `vera check` and fails `vera compile`; `string_join` on the same binder fails identically. Loud rather than wrong, and narrow: `array_length(map_keys(...))` compiles, so `Array` alone is fine, and `json_get_array`'s `Some(@Array)` binder compiles, so a binder alone is fine — it is the pair that breaks. Fix direction: give the binder's local the array's own value type instead of its element's. | [#1305](https://github.com/aallan/vera/issues/1305) | | `md_parse` diverges between the native and browser runtimes on **211 of 1,471** adversarial inputs (14.3%) and **329 of 4,858** blank-line-separated sections of the project's own documentation (6.8%), measured at [PR #1303](https://github.com/aallan/vera/pull/1303) by comparing the two ADTs directly rather than their renders. The second denominator is the repository's own Markdown, so it moves whenever a document gains or loses a section; 11 of those 4,858 also differ in the rendered output. Nine classes, each with a one-line repro. The largest by a wide margin — 173 of the 211 — is *plain-text run grouping*: the browser emits one `MdText` per scan segment where the reference coalesces adjacent runs, so `**unclosed` is `[MdEmph([]), MdText("unclosed")]` natively and `[MdText("*"), MdText("*unclosed")]` in the browser. That class is invisible to `md_render` (the runs concatenate to the same text) but not to a Vera program that matches on the ADT, which is what makes it a §12.9.3 violation rather than a cosmetic one. The rest are render-visible: emphasis/strong scanning (`***both***` renders `**both****` natively, `***both***` in the browser); list-continuation indent width, where the reference strips exactly two (or three) characters and the browser strips all leading whitespace (`- a\n b` → `- a b` vs `- a b`); a `+` bullet, unrecognised in the browser; an `n)` ordered marker, likewise; a loose list, one list natively and two in the browser (`- a\n\n- b`); nesting past two levels, flattened in the browser (`- a\n - b\n - c`); a thematic break with internal spaces (`* * *`); and a table without a separator row (`\| a \| b \|\n- li`). Neither implementation is the specification — §9.7.3 pins the ADT, not the grammar that produces it — so closing this means choosing a parse for each class and stating it, then mirroring. Successor to [#1294](https://github.com/aallan/vera/issues/1294), which closed the `md_render` half; the parity suite pins the shapes they do agree on, so a regression on one of those goes red. | [#1301](https://github.com/aallan/vera/issues/1301) | | A postcondition may name a `State` the function's effect row never declares, and `vera check` accepts it: `ensures(new(State) == false)` under `effects(>)` reports OK, then `vera compile` fails with **E699** — the internal-compiler-error diagnostic whose own text says the type checker should have rejected the input, which is exactly the situation. Both forms land there for the same reason (no cell of that family exists, so `old()` finds no snapshot local and, since [#1285](https://github.com/aallan/vera/issues/1285), `new()` finds no getter). Loud and never a wrong answer, so this is diagnostic quality rather than soundness — but it is a check-green program that cannot compile, reported against the compiler instead of against the program, with a bug-report request the user should not act on. Before #1285 the `new()` side was worse than loud: the name-keyed lookup found the row's other getter and silently read the wrong cell. Fix direction: validate an `OldExpr`/`NewExpr`'s effect reference against the declared row where the checker already validates the rest of the clause, one rule for both forms; `test_a_family_the_row_does_not_declare_is_loud_on_both_sides` pins today's E699 and is the test to flip. | [#1298](https://github.com/aallan/vera/issues/1298) | | Spec §1.4 says its reserved keywords MUST NOT be used as function names, and for **seventeen of them nothing enforces it**: `then`, `else`, `data`, `type`, `module`, `import`, `public`, `private`, `requires`, `ensures`, `invariant`, `decreases`, `effect`, `with`, `in`, `where` and `pure` all declare and pass `vera check` as `public fn (@Int -> @Int)`. (`handle` is an eighteenth acceptance and is the sanctioned host-entry-point carve-out, §5.2; the other eleven names — `resume`, `exists`, `forall`, `match`, `assert`, `assume`, `fn`, `let`, `if`, `true`, `false` — are refused **E153**.) They are not even traps: `private fn with(@Int -> @Int)` and its `where` / `type` / `pure` / `requires` / `import` siblings are **callable** from the same file, so the declaration resolves and runs like any other function. The comment above `_KEYWORD_FN_NAMES` in `vera/checker/registration.py` gives the opposite as the reason those names are absent from the set — that the contextual lexer "does not admit them as a function name, so no declaration reaches this checker at all" — so the omission rests on a premise the tree refutes, and neither the parser nor the checker holds the MUST. Two resolutions, and the choice is a ruling rather than a mechanical fix: extend `_KEYWORD_FN_NAMES` so the seventeen join `resume` under E153, or narrow §1.4's MUST to the names actually reserved. The `resume` precedent leans to enforcing — `resume` is likewise not a keyword token anywhere and likewise parses, and was reserved *at the checker* precisely because the parser had no keyword to refuse it with — and E153's own rationale, that a keyword names one construct and nothing else (DESIGN principle 3), does not depend on the name being a trap. Until the ruling lands, §1.4's `handle` carve-out sentence deliberately makes no enforcement claim, so the spec does not assert a MUST it cannot back. | [#1296](https://github.com/aallan/vera/issues/1296) | diff --git a/TESTING.md b/TESTING.md index 683ae64b..e9488eb1 100644 --- a/TESTING.md +++ b/TESTING.md @@ -151,6 +151,8 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_dropped_entry_1183_1186.py` | 21 | 561 | A dropped entry function is refused, never silently replaced (#1183), and an imported body's skip locates in its own module (#1186). #1183: the repro — declared `main` dropped, one public sibling surviving — exits nonzero with `main` and the root [E602]/[E620] named, and the sibling's 4243 sentinel (a value no fallback, default, or error path produces) never appears on stdout; the same for an explicit `--fn`, for the `--json` envelope (`ok: false`), and at the `execute()` library boundary; `CompileResult.dropped_fns` is pinned as the reified source of the refusal; the `Compilation notes:` block appears when a sibling survives (the ungating); auto-selection survives for the never-declared case and prints a one-line stderr note naming its choice; zero-export `compile` exits nonzero in both text and JSON; and the browser bundle refuses a dropped `main` using the SURVIVING-sibling fixture, so a non-empty export list rules out the zero-export check as the cause. #1186: the root E602 carries the MODULE's path with module-local line/column and quotes the module's source line, the [E620] cross-file prefix fires with its exact wording derived from the root's own location, a same-file control keeps the bare `at line N, column M` form, and `vera test` names the [E602] root instead of calling a public-but-dropped function private. Mutations — api.py refusal, CLI refusal, the E620 drop record, the module source scope, the tester reason, the notes ungating, the auto-select note, the zero-export gate, the browser refusal — each killed by a named test | | `test_imported_trap_source_map_1189.py` | 8 | 342 | An imported function's runtime trap frame names ITS module's file (#1189), the source-map sibling of #1186's diagnostic fix. Fixtures split the basenames (`chinchilla.vera` module, `stargazer.vera` importer) so a frame's attribution is decidable from the string alone, and every trap is a precondition violation so `WasmTrapError.kind` is pinned. Covers the three doors: an imported non-generic fn (pre-fix `` — never registered on the main generator), a monomorphized clone of an imported generic (pre-fix the IMPORTER's path with the module's line range, which in the fixture names a real-but-unrelated importer function), and the `mod$…` emission of a locally-shadowed import (whose rightmost-`$` strip yields nobody's entry). Asserted at the `cmd_run` text backtrace (per-frame line, never the whole stderr blob — `main` legitimately names the importer), the `--json` frames array, `fn_source_map` itself, and `execute()`'s `WasmTrapError.frames`. Over-correction control: a wholly main-file trap keeps the main file, green before and after. Mutations — the module file on the Pass-0.5 registrar, the bare-name harvest, the mangled-name mirror, the Pass-1.5 module source scope — each killed by a distinct named test | | `test_codegen_typeparam_unit_wildcard_1060.py` | 31 | 959 | Wildcard over a type-parameter field instantiated to `Unit` (#1060), the type-parameter sibling of #1043's declared-`Unit` field: a WILDCARD over `Box` field `T` used to advance the match offset walk by the generic `i32` width, so on `Box` (field erased to 0 bytes) every later field read four bytes high — silently check-green. Bug-manifesting shapes go end-to-end (`Box` trailing-`Int`, `Named` `String` read-back, `Entry` nested-ctor tag, `Bool`-following, second-type-parameter, and a nested-generic `Outer` wrapping `Inner` that exercises the deeper-recursion type substitution); controls stay green (before-erased field, trailing wildcards, `Option`/`Result` builtins, `Box`/`Box`/`Tagged` alignment-coincidence, structural `Eq`/`show` recompute path); the direct-call boundary is pinned (#1065) — a `match mk() { … }` scrutinee recovers its concrete instantiation from the callee's declared return type, so a wildcard followed by a read now compiles and reads the real value (`Box` trailing-`Int`, `Entry` nested-ctor, `Named` `String` read-back) instead of the sound #1060 interim LOUD-skip, while a trailing direct-call wildcard still compiles; the generic-call sibling (#1072) resolves the declared return's type variables from the call site (`P2` at T=Int, the var-typed field at `String` i32_pair width, a fully concrete parameterized return on a generic fn, nested-ctor and `String`-read-back variants, plus a trailing-wildcard control), and the module-call door (#1073) routes `boxlib::mk()` — and the imported-generic #1072 x #1073 compound — through the shared resolver into the same recovery; mutation-validated per arm (reverting the #1060 instantiation-awareness flips exactly the #1060 bug-manifesting shapes RED with declared-`Unit` #1043 tests green; reverting the #1065 declared-return threading flips exactly the three direct-call value shapes RED; neutralizing the #1072 generic arm flips exactly the five generic value shapes + the imported-generic compound RED; neutralizing the #1073 module arm flips exactly the two module tests RED) | +| `test_codegen_alias_adt_name_width_1309.py` | 107 | 328 | A `type` alias whose name is also a registered ADT's must emit the ALIAS TARGET's width (#1309). Codegen's `_type_expr_to_wasm_type` tested `_adt_layouts` — and `Array`/`Map`/`Set`/`Decimal`, none of them primitives — before the alias table, where the checker's `_resolve_named` resolves primitive, then alias, then declared ADT; so `type Option = Int;` emitted the ADT's i32 pointer for an i64 slot on a check-green, verify-green program. Three dispositions are pinned separately because they fail differently: LOUD scalar targets (`Int`/`Nat` i64, `Float64` f64) died at load; PAIR targets are the SILENT ones the issue's "matching widths" prediction missed — an `i32_pair` is two words and the single i32 dropped the length, so `string_concat("ab", "ab")` returned junk bytes and `array_length` over three elements returned 0, both at exit 0; and matching-width targets (`Bool`/`Byte`/`Map`/`Set`/`Decimal`, all i32) are INERT, kept as green-both-sides guards that the reorder leaves them alone. The battery is the differential that makes width-luck unreintroducible: every name in the LIVE built-in ADT registry (read off a real `CodeGenerator`, so a new built-in joins without anyone widening a list) crossed with every representation class, comparing the emitted `twice` signature against the identical program under a fresh alias name, plus the two unit duals — under an alias the derivation answers the target's width, without one it still answers the ADT pointer, so "the alias wins" cannot be satisfied by breaking every ordinary ADT parameter. Primitives are asserted to still shadow a same-named alias — the one branch that must NOT move — across all seven spellings by asking the derivation directly, which is the only way to reach every one of them since two are checker-refused (`@Bool.0 + @Bool.0` is E140, `type Int = Int;` is E132); a run-level program carries the behavioural half, because `type Bool = Int;` used AS a Bool is check-green, runs, and distinguishes the hoist mutant. An earlier draft claimed the checker refuses every program that would exercise this, which is measured false. `Json` and `HtmlNode` are deliberately outside the prelude-ADT row: their prelude combinator bodies render against the flat alias map a main-file shadow pollutes, which is an alias-env SCOPING defect (#1316) the reorder does not reach — though it does MOVE that failure (17 prelude `json_*` signatures flip width, the loader's complaint reverses direction, `html_attr` loses a push), so "fails identically" — an earlier draft's wording — is measured false | +| `test_codegen_pair_scrutinee_1305.py` | 32 | 685 | A `match` whose SCRUTINEE is pair-represented (`String` / `Array`) took one local at the internal `i32_pair` pseudo-type, so the module carried `(local $l1 i32_pair)` and never assembled (#1305). The issue reached it through `json_keys` and framed it as an `Option>` payload binder; the docstring records why measurement does not support that — `json_keys` returns `Array`, and `array_length(json_keys(j))` compiled and ran at the branch point — so the tests are built on the shapes that actually trigger it: `match @String.0 { @String -> … }` and `match @Array.0 { @Array -> … }`, both legal and check-green, plus the slot, call-result and builtin-result scrutinee forms. One test returns the BOUND STRING rather than its length, because a fix that allocated two locals and copied only the pointer passes every length-free assertion. The issue's own repro matches `Some`/`None` against that array; a pair carries no tag, so the assertion is that the module assembles and the refusal is a located E602 — not that the nonsense compiles (the checker accepting it is #1315). The guard is a WHITELIST (wildcard and binding only) and these cells are why: as a blacklist naming the two constructor kinds it let `true ->` and `1 ->` fall through into the arm-condition emitter, turning a loud WAT failure into a check-green program that exits 0 printing 100 from the scrutinee's heap POINTER read as a truth value, and its integer twin into a shipped `.wasm` that died at instantiation with no diagnostic — so five unlowerable-arm cells (bool/int/string literals over both pair spellings) and a nullary-arm-FIRST program pin each half, the last because both original repros led with `Some` and left the nullary half droppable green. The two shadow pushes are pinned as EMISSION by a WAT differential against the match-free twin (exactly two more push idioms) plus a position assertion that no length half is rooted: deleting both pushes leaves the whole suite, the GC rooting and reclamation suites, and four allocate-in-the-arm probes under `VERA_EAGER_GC=1` green, so a behavioural claim would be one no probe supports. The two controls the issue listed as already-compiling are kept as regression guards, and an Option/Result binder battery over `Array`/`Array`/`Array`/`Map`/`Set`/`String`/`Int` payloads plus a nested `Option>>` pins the boundary: the scrutinee change left pair-typed constructor FIELDS alone | | `test_codegen_erased_alias_typeargs_1070.py` | 15 | 398 | Non-literal erases-to-Unit type ARGUMENTS (#1070): `Box` (`type U = Unit;`), `Box>`, `Box`, and alias chains — the #1060 width recomputation's zero-size test was the literal name `Unit`, so these spellings got 4 bytes and every later field read a shifted offset (silent 22→0, nested 314→0); the same literal-test disease made structural `Eq` over the same spellings fall back to the scalar POINTER compare (pre-existing — equal structs compared unequal, silently) and `show`/`hash` loud-skip. Pins every spelling end-to-end: wildcard reads (trailing `Int`, nested ctor), `Eq` equal/distinct + `Future` arg, `show` renders `unit` + the real fields, `hash` payload-sensitive + deterministic, literal-`Unit` controls; the rider — a zero-size `@Unit` BINDING after an unrecoverable wildcard is not a read (compiles), while a genuine read beyond it still LOUD-skips (E602); mutation-validated per site (width fn, field-name canonicalisation, both dispatch gates, derivability gate, rider — each flips exactly its own test subset) | | `test_codegen_alias_typeargs_eq_1076.py` | 34 | 499 | The Eq-dispatch ground-spelling cluster (#1076/#1077/#1078). #1076: structural `==` over NON-Unit alias type args (`Box`/`MyStr`/`MyBool`/`Future`/chains) silently pointer-compared (equal structs → 0, check-green) — equal AND distinct pairs per spelling, i64-width pins with >2^32 payloads whose low 32 bits collide, `MyStr` content-vs-pointer compare, plus the #1060-walk width shapes (`Bool` follows the type-param field, so an i32-sized `MyInt`/`MyStr`/`Future` manifests); a genuine free `T` (dead base-generic clone, #912) still compiles via its scalar fallback. #1077: `show`/`hash` of `Tuple` (raw-args Tuple plan branch) and of bare aliased-Unit values (literal-name top-level arms) loud-skipped — all four now compute (exact renders, payload-sensitive hash), literal controls pinned. #1078: element-wise `==` on arrays of parameterized ADTs (`Array>`, literal included) pointer-compared (the `IndexExpr` operand's element head drops its type args) — equal/distinct/`>2^32`/aliased-element shapes, non-generic-array + direct-compare controls; mutation-validated per site (canonicalization helper, width fn, both dispatch gates, derivability gate, Tuple plan branch, top-level Unit arms, IndexExpr recovery arm — each flips exactly its own subset) | | `test_codegen_alias_of_adt_eq_show_1085.py` | 49 | 1,140 | The alias-of-ADT / forall-Eq / bare-Future dispatch cluster (#1085/#1086/#1087 + the PR #1090 review round: #1091/#1092), siblings of #1076/#1077 at entry points the ground-spelling pass never reached. #1085: structural `==` over an alias of a WHOLE ADT (`type MyBox = Box;`, `@MyBox.0 == @MyBox.1`) silently pointer-compared (equal structs → 0, check-green) — the operand reaches the dispatch as the bare alias name, absent from `_adt_type_names`; equal AND distinct pairs, i64-width `>2^32` low-bits-collide pins, `String`-content, non-generic-ADT and alias-chain shapes, direct-compare control, plus the refinement-over-whole-ADT `==` (`type NB = { @Box \| true };` — the same silent pointer compare, equal + distinct pins). #1086: a `forall>` instantiated at an Eq alias (`@Box`) wrong-loud E613'd (the top-level constraint gate misses alias / `Future` spellings) — equal / distinct / i64 pins for `MyInt` and `Future`, an alias-of-whole-ADT positive, plus a non-Eq alias (`Array`) differential (still E613, never the codegen E699 — gate↔codegen lockstep, #732). #1087: `show` / `hash` of a bare or aliased `Future` value loud-skipped (E602) — the inferred type reaches the top-level dispatch un-peeled; aliased + bare `Future` / `Future` renders, payload-sensitive i64 hash, plain-Int + aliased-primitive + refinement controls. #1091 + composite-Future (PR #1090 review): the composite path's `_parameterized_arg_type` recovery UNDID the grounding — bare/aliased `Future>` show+hash, alias-of-whole-ADT show/hash (`type MyBox = Box;`, `type MB = Box;`), `@Array` slot + array-literal element grounding, with Tuple-component and ctor-argument GREEN pins (grounded at plan consumption, #1076/#1077). #1092: an in-range int literal coerced into a `@Byte`-instantiated generic ctor field was stored i64 while every reader sizes i32 — `MkB(0) == MkB(255)` silently equal, extraction read 0 for a stored 255; distinct + equal + extraction + aliased-forall-Eq shapes through the full checked pipeline (`_run_checked` threads the checker's target-type table exactly as the CLI does), passthrough + `Int`-instantiation controls; mutation-validated per site (operand grounding, gate fallback, show + hash grounding, recovery grounding, Array-arm element grounding, Byte width coercion — each flips exactly its own subset) | diff --git a/tests/conformance/ch04_match_pair_scrutinee.vera b/tests/conformance/ch04_match_pair_scrutinee.vera new file mode 100644 index 00000000..2d140917 --- /dev/null +++ b/tests/conformance/ch04_match_pair_scrutinee.vera @@ -0,0 +1,44 @@ +-- Conformance: match on a pair-represented scrutinee (Chapter 4) +-- Tests: String and Array match scrutinees, whose (ptr, len) pair binds +-- into two consecutive locals rather than one (#1305). The json_keys arm +-- is the shape the issue was found on. +private fn measure(@String -> @Int) + requires(true) + ensures(@Int.result >= 0) + effects(pure) +{ + match @String.0 { + @String -> string_length(@String.0) + } +} + +private fn count(@Array -> @Int) + requires(true) + ensures(@Int.result >= 0) + effects(pure) +{ + match @Array.0 { + @Array -> array_length(@Array.0) + } +} + +private fn key_count(@Json -> @Int) + requires(true) + ensures(@Int.result >= 0) + effects(pure) +{ + match json_keys(@Json.0) { + @Array -> array_length(@Array.0) + } +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(@Int.result == 10) + effects(pure) +{ + match json_parse("{\"a\": 1, \"b\": 2}") { + Ok(@Json) -> key_count(@Json.0) + measure("hello") + count([1, 2, 3]), + Err(@String) -> 0 - 1 + } +} diff --git a/tests/conformance/ch08_alias_shadows_prelude_adt_name.vera b/tests/conformance/ch08_alias_shadows_prelude_adt_name.vera new file mode 100644 index 00000000..74586c82 --- /dev/null +++ b/tests/conformance/ch08_alias_shadows_prelude_adt_name.vera @@ -0,0 +1,39 @@ +-- Conformance: a type alias shadowing a prelude ADT name (Chapter 8) +-- Tests: alias-over-ADT resolution reaching the emitted WASM width +-- Spec 8.4.1: the prelude's data types are ordinary public declarations a +-- program names, and shadows, like any other. The alias target's width is +-- what the slot emits, not the shadowed ADT's pointer width (#1309). +-- `Option` aliases a scalar (i64 where the ADT pointer is i32) and `Result` +-- aliases a pair (two words where the ADT pointer is one), so a resolution +-- that took the ADT branch traps on the first and silently drops the length +-- word on the second. Both asserts are Tier-3 runtime checks, so either +-- wrong width trips one of them. +type Option = Int; + +type Result = String; + +private fn twice(@Option -> @Int) + requires(true) + ensures(@Int.result == @Option.0 + @Option.0) + effects(pure) +{ + @Option.0 + @Option.0 +} + +private fn doubled_length(@Result -> @Int) + requires(true) + ensures(@Int.result >= 0) + effects(pure) +{ + string_length(string_concat(@Result.0, @Result.0)) +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(@Int.result == 42) + effects(pure) +{ + assert(doubled_length("ab") == 4); + assert(twice(21) == 42); + 42 +} diff --git a/tests/conformance/manifest.json b/tests/conformance/manifest.json index b877b6f7..bc07a935 100644 --- a/tests/conformance/manifest.json +++ b/tests/conformance/manifest.json @@ -1202,7 +1202,7 @@ "id": "ch07_state_clause_transform", "file": "ch07_state_clause_transform.vera", "chapter": 7, - "title": "State clause bodies execute — intrinsic-hybrid semantics (#976)", + "title": "State clause bodies execute \u2014 intrinsic-hybrid semantics (#976)", "level": "run", "spec_ref": "Section 7.5.2", "features": [ @@ -1261,9 +1261,9 @@ "id": "ch07_exn_string", "file": "ch07_exn_string.vera", "chapter": 7, - "title": "Exn effect handler — String payload", + "title": "Exn effect handler \u2014 String payload", "level": "run", - "spec_ref": "Section 7.4 Exn handler — String payload", + "spec_ref": "Section 7.4 Exn handler \u2014 String payload", "features": [ "exn_handler", "string_exception" @@ -1696,7 +1696,7 @@ "id": "ch08_transitive_module_import_base", "file": "ch08_transitive_module_import_base.vera", "chapter": 8, - "title": "Transitive import chain — base module", + "title": "Transitive import chain \u2014 base module", "level": "check", "spec_ref": "Section 8.6.4", "features": [ @@ -1708,7 +1708,7 @@ "id": "ch08_transitive_module_import_mid", "file": "ch08_transitive_module_import_mid.vera", "chapter": 8, - "title": "Transitive import chain — middle module", + "title": "Transitive import chain \u2014 middle module", "level": "verify", "spec_ref": "Section 8.6.4", "features": [ @@ -1875,7 +1875,7 @@ "chapter": 9, "title": "Json ADT and json_* built-in functions", "level": "run", - "spec_ref": "Section 9.4.4", + "spec_ref": "Section 9.7.1", "features": [ "Json", "JNull", @@ -3431,5 +3431,34 @@ "adt", "resolution_error" ] + }, + { + "id": "ch08_alias_shadows_prelude_adt_name", + "file": "ch08_alias_shadows_prelude_adt_name.vera", + "chapter": 8, + "title": "Type alias shadowing a prelude ADT name", + "level": "run", + "spec_ref": "Section 8.4.1", + "features": [ + "type_alias", + "prelude_adt_shadow", + "alias_resolution", + "assert" + ] + }, + { + "id": "ch04_match_pair_scrutinee", + "file": "ch04_match_pair_scrutinee.vera", + "chapter": 4, + "title": "Match on a String / Array scrutinee", + "level": "run", + "spec_ref": "Section 4.9", + "features": [ + "match", + "binding_pattern", + "string", + "array", + "json_keys" + ] } ] diff --git a/tests/test_codegen_alias_adt_name_width_1309.py b/tests/test_codegen_alias_adt_name_width_1309.py new file mode 100644 index 00000000..5a2b9770 --- /dev/null +++ b/tests/test_codegen_alias_adt_name_width_1309.py @@ -0,0 +1,328 @@ +"""#1309 — a `type` alias whose name is also a registered ADT name. + +Codegen's ``_type_expr_to_wasm_type`` used to consult ``_adt_layouts`` +BEFORE the alias table, so ``type Option = Int;`` emitted the slot at the +ADT's i32 pointer width instead of the alias target's i64. The checker +resolves the other way (``vera/naming.py::_resolve_named`` — type parameter +-> primitive -> alias -> declared ADT), so check and verify were green and +the disagreement surfaced only in the emitted WAT. + +Two failure modes were measured at the branch point, and they are NOT the +"same width is silently wrong" story the issue predicted: + +* **Loud** where the widths differ AND the target is a scalar (``Int`` / + ``Nat`` -> i64, ``Float64`` -> f64): the module fails WASM validation at + load with ``type mismatch: expected i64, found i32``. +* **Silent** where the target is a PAIR type (``String`` / ``Array`` -> + ``i32_pair``, two words): the ADT branch's single i32 drops the length + word, the module validates, and the program runs to completion with a + WRONG VALUE — ``string_concat("ab", "ab")`` returned two junk bytes + instead of ``"abab"``, and ``array_length`` over a 3-element array + returned 0. +* **Inert** where the widths coincide (``Bool`` / ``Byte`` / ``Map`` / + ``Set`` / ``Decimal``, all i32): the emitted WAT is byte-identical to a + fresh-name control. Matching widths are not a silent-wrongness case; + the pair types are. + +:class:`TestAliasOverAdtNameWidthBattery` is the differential that makes +width-luck impossible to reintroduce: every name in the LIVE built-in ADT +registry, aliased to every representation class, with the emitted function +signature compared against the identical program under a fresh alias name. +""" +from __future__ import annotations + +import pytest + +from vera import ast +from vera.codegen import CodeGenerator, execute + +from tests.codegen_helpers import _compile, _compile_ok, _run, wat_fn_body + + +# The control name: not a registered ADT, not a primitive, not a prelude +# alias — so the alias branch is the only branch that can claim it. +_CONTROL = "ZzAliasCtl" + + +def _builtin_adt_names() -> list[str]: + """Every built-in ADT name, from the LIVE registry. + + Read off a real ``CodeGenerator`` rather than restated here, so a + built-in ADT added later joins the battery without anyone remembering + to widen a list. + """ + gen = CodeGenerator() + gen._register_builtin_adts() + return sorted(gen._adt_layouts) + + +# (alias target spelling, argument literal, body, declared return type) +# One entry per WASM representation class the target can land in. +_TARGETS: list[tuple[str, str, str, str]] = [ + ("Int", "21", "@{A}.0 + @{A}.0", "@Int"), + ("Nat", "21", "@{A}.0 + @{A}.0", "@Nat"), + ("Float64", "1.5", "@{A}.0 + @{A}.0", "@Float64"), + ("Bool", "true", "if @{A}.0 then {{ 7 }} else {{ 9 }}", "@Int"), + ("Byte", "3", "byte_to_int(@{A}.0) + byte_to_int(@{A}.0)", "@Int"), + ("String", '"ab"', "string_concat(@{A}.0, @{A}.0)", "@String"), + ("Array", "[5, 6, 7]", "array_length(@{A}.0)", "@Nat"), + ("Map", "map_new()", "map_size(@{A}.0)", "@Nat"), + ("Set", "set_new()", "set_size(@{A}.0)", "@Nat"), + ("Decimal", "decimal_from_int(3)", "decimal_to_string(@{A}.0)", "@String"), +] + + +def _program(alias: str, target: str, lit: str, body: str, ret: str) -> str: + """The same two-function program, parameterised by the alias name.""" + return ( + f"type {alias} = {target};\n\n" + f"public fn twice(@{alias} -> {ret})\n" + " requires(true)\n" + " ensures(true)\n" + " effects(pure)\n" + "{\n" + f" {body.format(A=alias)}\n" + "}\n\n" + f"public fn main(@Unit -> {ret})\n" + " requires(true)\n" + " ensures(true)\n" + " effects(pure)\n" + "{\n" + f" twice({lit})\n" + "}\n" + ) + + +def _run_value(source: str, fn: str = "main") -> object: + """Execute and return the raw value (str for String returns, else scalar).""" + result = _compile_ok(source) + return execute(result, fn_name=fn).value + + +class TestAliasOverAdtNameLoud: + """Scalar targets: the widths differ, so the base failure was at load.""" + + def test_int_alias_named_option_runs(self) -> None: + """The issue's own repro. + + Base: ``Invalid input WebAssembly code at offset 119: type + mismatch: expected i64, found i32`` from ``vera run``, on a + check-green / verify-green (4 Tier-1) program. + """ + source = _program("Option", "Int", "21", "@{A}.0 + @{A}.0", "@Int") + assert _run(source, fn="main") == 42 + + def test_float_alias_named_result_runs(self) -> None: + source = _program( + "Result", "Float64", "1.5", "@{A}.0 + @{A}.0", "@Float64") + assert _run_value(source) == pytest.approx(3.0) + + +class TestAliasOverAdtNameSilent: + """Pair targets: the module VALIDATES and computes the wrong answer. + + This is the silent case the issue's "matching widths" prediction + missed. An ``i32_pair`` is two words; the ADT branch's single i32 + silently discards the length, and nothing traps. + """ + + def test_string_alias_named_option_keeps_the_bytes(self) -> None: + """Base: exit 0, returned two junk bytes instead of ``"abab"``.""" + source = _program( + "Option", "String", '"ab"', "string_concat(@{A}.0, @{A}.0)", + "@String") + assert _run_value(source) == "abab" + + def test_array_alias_named_option_keeps_the_length(self) -> None: + """Base: exit 0, ``array_length`` returned 0 for a 3-element array.""" + source = _program( + "Option", "Array", "[5, 6, 7]", "array_length(@{A}.0)", + "@Nat") + assert _run(source, fn="main") == 3 + + +class TestAliasOverAdtNameInert: + """Matching-width targets: green before AND after — a regression guard. + + Green on both sides of the fix proves nothing about the fix; these are + here so the branch reorder cannot break the cases it must leave alone. + """ + + def test_bool_alias_named_ordering_still_runs(self) -> None: + source = _program( + "Ordering", "Bool", "true", "if @{A}.0 then {{ 7 }} else {{ 9 }}", + "@Int") + assert _run(source, fn="main") == 7 + + def test_map_alias_named_tuple_still_runs(self) -> None: + source = _program( + "Tuple", "Map", "map_new()", "map_size(@{A}.0)", + "@Nat") + assert _run(source, fn="main") == 0 + + +class TestBuiltinContainerNameShadow: + """The alias branch must also beat the built-in CONTAINER branches. + + ``Array`` / ``Map`` / ``Set`` / ``Decimal`` / ``Tuple`` are not + primitives in the checker (``vera.types.PRIMITIVES``), so an alias + taking one of those names wins there too — and codegen tested them + before the alias table exactly as it tested ``_adt_layouts``. Base: + each of these died with ``expected i64, found i32``. + """ + + @pytest.mark.parametrize( + "name", ["Array", "Map", "Set", "Decimal", "Tuple", "Future"]) + def test_alias_named_after_a_builtin_container_runs( + self, name: str, + ) -> None: + source = _program(name, "Int", "21", "@{A}.0 + @{A}.0", "@Int") + assert _run(source, fn="main") == 42 + + @pytest.mark.parametrize("name", ["Request", "Response", "UrlParts"]) + def test_alias_named_after_a_prelude_adt_runs(self, name: str) -> None: + """A prelude-DECLARED ADT's name, shadowed by a main-file alias. + + ``Json`` and ``HtmlNode`` are deliberately absent: their prelude + COMBINATOR BODIES (``json_get``, ``html_attr``) are emitted into + every module and render their own ``@Json`` / ``@HtmlNode`` + parameters against the flat ``_type_aliases`` map, which a main-file + alias of that name pollutes. That is an alias-ENV SCOPING defect + (#1316 — spec §8.4.1 makes the namespace module-scoped, so a prelude + body must render against the prelude's env), not the branch-ORDER + defect fixed here, and out of #1309's scope. + + It is NOT, however, an unchanged failure, and an earlier draft of + this docstring said it was. The reorder moves it: 17 prelude + ``json_*`` signatures flip from ``(param $p0 i32)`` to ``(param $p0 + i64)``, the loader's complaint reverses from ``expected i64, found + i32`` to ``expected i32, found i64``, its offset shifts, and + ``html_attr`` loses one shadow-stack push. Same root cause, same + frame in the backtrace, a later point inside it. + """ + source = _program(name, "Int", "21", "@{A}.0 + @{A}.0", "@Int") + assert _run(source, fn="main") == 42 + + def test_primitive_shadow_runs_at_the_primitive_width(self) -> None: + """The behavioural half, and the one that can go wrong silently. + + ``type Bool = Int;`` then using ``@Bool`` AS a Bool is check-green + and runs: the primitive branch wins, so the slot stays i32. Hoist + the alias branch above the primitives and ``@Bool`` becomes i64 — + which is why the unit assertions below are not the whole story, and + why the claim they once carried (that the checker refuses every + program exercising this) was simply false. ``@Bool.0 + @Bool.0`` + is indeed E140 and ``type Int = Int;`` is E132, but reading the + slot as the primitive it resolves to is neither. + """ + source = """\ +type Bool = Int; + +private fn f(@Bool -> @Int) + requires(true) + ensures(@Int.result >= 0) + effects(pure) +{ + if @Bool.0 then { 1 } else { 2 } +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(@Int.result >= 0) + effects(pure) +{ + f(true) +} +""" + assert _run(source, fn="main") == 1 + + @pytest.mark.parametrize("name", ["Int", "Nat", "Bool", "Float64", + "String", "Byte", "Unit"]) + def test_primitive_still_beats_a_same_named_alias(self, name: str) -> None: + """The one branch that must NOT move: a primitive shadows the alias. + + ``_resolve_named`` tests ``PRIMITIVES`` before the alias table, so + ``type Bool = Int;`` leaves ``@Bool`` a Bool. Asked of the + derivation directly because that is the only way to cover all seven + primitives — two of the spellings are refused by the checker + (``@Bool.0 + @Bool.0`` is E140, ``type Int = Int;`` is E132) — with + the run-level program above carrying the behavioural half. + """ + gen = CodeGenerator() + gen._register_builtin_adts() + gen._type_aliases[name] = ast.NamedType(name="Int", type_args=None) + gen._sync_alias_env() + expected = { + "Int": "i64", "Nat": "i64", "Bool": "i32", "Float64": "f64", + "String": "i32_pair", "Byte": "i32", "Unit": None, + }[name] + assert gen._type_expr_to_wasm_type( + ast.NamedType(name=name, type_args=None)) == expected + + +class TestAliasOverAdtNameWidthBattery: + """The differential: EVERY built-in ADT name x EVERY representation. + + For each pair, compile the alias-named program and the identical + program under a fresh alias name, and compare the emitted ``$twice`` + signature — the parameter, local and result widths. A single width + that resolves through the ADT branch instead of the alias branch shows + up here regardless of whether it happens to trap, so the loud cases + cannot be the only ones anyone notices. + """ + + @pytest.mark.parametrize("adt", _builtin_adt_names()) + @pytest.mark.parametrize( + "target,lit,body,ret", _TARGETS, + ids=[t[0] for t in _TARGETS], + ) + def test_emitted_widths_match_the_fresh_name_control( + self, adt: str, target: str, lit: str, body: str, ret: str, + ) -> None: + aliased = _compile(_program(adt, target, lit, body, ret)) + control = _compile(_program(_CONTROL, target, lit, body, ret)) + + control_errors = [ + d for d in control.diagnostics if d.severity == "error"] + assert not control_errors, ( + f"control program for {target} is itself broken: {control_errors}") + + aliased_errors = [ + d for d in aliased.diagnostics if d.severity == "error"] + assert not aliased_errors, ( + f"type {adt} = {target}; failed to assemble: {aliased_errors}") + + want = wat_fn_body(control.wat, "twice").splitlines()[0] + got = wat_fn_body(aliased.wat, "twice").splitlines()[0] + assert got == want, ( + f"type {adt} = {target}; emitted {got!r}, " + f"but the same program under a fresh alias name emitted {want!r}" + ) + + @pytest.mark.parametrize("adt", _builtin_adt_names()) + def test_derivation_follows_the_alias_not_the_adt(self, adt: str) -> None: + """The branch order itself, at the one function that decides it. + + The unit dual of the WAT differential above: under ``type = + Int;`` the derivation must answer ``i64``. ``i32`` is the ADT + pointer width — the bug. + """ + gen = CodeGenerator() + gen._register_builtin_adts() + gen._type_aliases[adt] = ast.NamedType(name="Int", type_args=None) + gen._sync_alias_env() + assert gen._type_expr_to_wasm_type( + ast.NamedType(name=adt, type_args=None)) == "i64" + + @pytest.mark.parametrize("adt", _builtin_adt_names()) + def test_unaliased_adt_name_is_still_a_pointer(self, adt: str) -> None: + """The complement: with no alias in scope the ADT branch still wins. + + Without this, "make the alias branch win" could be satisfied by a + change that broke every ordinary ADT parameter. + """ + gen = CodeGenerator() + gen._register_builtin_adts() + gen._sync_alias_env() + assert gen._type_expr_to_wasm_type( + ast.NamedType(name=adt, type_args=None)) == "i32" diff --git a/tests/test_codegen_pair_scrutinee_1305.py b/tests/test_codegen_pair_scrutinee_1305.py new file mode 100644 index 00000000..5d8d1680 --- /dev/null +++ b/tests/test_codegen_pair_scrutinee_1305.py @@ -0,0 +1,685 @@ +"""#1305 — a `match` whose SCRUTINEE has the (ptr, len) pair representation. + +``_translate_match`` saved the scrutinee into ONE local allocated at the +inferred WAT type. A ``String`` or ``Array`` scrutinee infers +``"i32_pair"`` — the two-word pseudo-type, not a WAT value type — so the +emitted module carried ``(local $l1 i32_pair)`` and never assembled: + + WAT compilation failed: unexpected token, expected one of: `i32`, ... + --> :588:16 + | + 588 | (local $l2 i32_pair) + +The issue reached this through ``json_keys``, and framed it as an +``Option>`` payload binder. Measured at the branch point, +that framing does not hold and the defect is wider: + +* ``json_keys`` returns ``Array``, not ``Option>`` + (``vera/environment.py`` ``functions["json_keys"]``, and the prelude body + in ``vera/prelude.py``), so its result was never a match BINDER problem — + ``array_length(json_keys(j))`` compiled and ran at the branch point. +* The trigger is the scrutinee's representation, nothing to do with JSON or + with ``Array``: ``match @String.0 { @String -> ... }`` and + ``match @Array.0 { @Array -> ... }`` — both legal, check-green, + single-binding matches — emitted the same invalid local. + +The issue's own repro additionally matches ``Some``/``None`` against an +``Array``. A pair type has no constructors and no tag, so codegen +cannot lower that; it now raises a LOUD skip naming the situation instead +of emitting a tag read over the array's first four bytes. (The checker +accepting that program at all is a separate hole, #1315, outside this fix.) +""" +from __future__ import annotations + +import re + +import pytest + +from vera.codegen import execute + +from tests.codegen_helpers import _compile, _compile_ok, _run, wat_fn_body + + +_PAIR_LOCAL_RE = re.compile(r"\(local \$\w+ i32_pair\)") + + +def _run_value(source: str, fn: str, args: list[object] | None = None) -> object: + result = _compile_ok(source) + return execute(result, fn_name=fn, args=args).value + + +def _assert_assembles(source: str) -> str: + """Compile, assert the module assembled, and return its WAT.""" + result = _compile(source) + errors = [d for d in result.diagnostics if d.severity == "error"] + assert not errors, f"did not assemble: {[d.description for d in errors]}" + assert not _PAIR_LOCAL_RE.search(result.wat), ( + "emitted an i32_pair local: " + f"{_PAIR_LOCAL_RE.findall(result.wat)}" + ) + return result.wat + + +class TestLegalPairScrutinee: + """The honest core of the bug: well-typed matches on a pair scrutinee.""" + + def test_match_binding_on_a_string_scrutinee(self) -> None: + """Base: ``(local $l2 i32_pair)``; the module never assembled.""" + source = """\ +public fn f(@String -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + match @String.0 { + @String -> string_length(@String.0) + } +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + f("abcd") +} +""" + _assert_assembles(source) + assert _run(source, fn="main") == 4 + + def test_match_binding_on_an_array_scrutinee(self) -> None: + source = """\ +public fn f(@Array -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + match @Array.0 { + @Array -> array_length(@Array.0) + } +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + f([1, 2, 3]) +} +""" + _assert_assembles(source) + assert _run(source, fn="main") == 3 + + def test_the_bound_string_keeps_its_bytes(self) -> None: + """Both halves must survive the bind, not just the pointer. + + A fix that allocated two locals but copied only the pointer would + still pass a length-free assertion; returning the bound value makes + the length load-bearing. + """ + source = """\ +public fn f(@String -> @String) + requires(true) + ensures(true) + effects(pure) +{ + match @String.0 { + @String -> string_concat(@String.0, "!") + } +} + +public fn main(@Unit -> @String) + requires(true) + ensures(true) + effects(pure) +{ + f("hi") +} +""" + assert _run_value(source, fn="main") == "hi!" + + def test_match_on_a_string_returning_call_scrutinee(self) -> None: + """A pair-typed CALL result, not a slot — the same local allocation.""" + source = """\ +public fn f(@String -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + match string_upper(@String.0) { + @String -> string_length(@String.0) + } +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + f("abc") +} +""" + _assert_assembles(source) + assert _run(source, fn="main") == 3 + + def test_match_on_an_array_returning_builtin_scrutinee(self) -> None: + source = """\ +public fn f(@Map -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + match map_keys(@Map.0) { + @Array -> array_length(@Array.0) + } +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + f(map_insert(map_insert(map_new(), "a", 1), "b", 2)) +} +""" + _assert_assembles(source) + assert _run(source, fn="main") == 2 + + +def _assert_refused(source: str, arm: str, entry: str = "main") -> None: + """The match arm *arm* is refused by a LOCATED skip, and nothing runs. + + Three assertions, because the failure modes they catch are different + and a subset of them passes on a broken compiler: + + * the module still assembles (no ``i32_pair`` local, no WAT failure) — + the #1305 symptom; + * a diagnostic names the pair representation AT the offending arm's own + line — a skip located on the enclosing function would read as if the + whole body were unsupported; + * the entry point does not survive, so no value can be returned. This + is the one that separates a refusal from the silent answer: without + it, the ``true ->`` arms below still export a ``main`` that returns + 100 from a heap pointer read as a truth value. + """ + result = _compile(source) + assert not _PAIR_LOCAL_RE.search(result.wat), ( + f"emitted an i32_pair local: {_PAIR_LOCAL_RE.findall(result.wat)}") + wat_failures = [ + d for d in result.diagnostics if "WAT compilation failed" in d.description + ] + assert not wat_failures, ( + f"module does not assemble: {[d.description[:120] for d in wat_failures]}") + + want_line = next( + i for i, ln in enumerate(source.splitlines(), 1) if arm in ln) + located = [ + d for d in result.diagnostics + if "(ptr, len) pair" in d.description + and d.location is not None and d.location.line == want_line + ] + assert located, ( + f"expected a pair-representation skip located at line {want_line} " + f"({arm!r}); got " + f"{[(d.location.line if d.location else None, d.description[:90]) for d in result.diagnostics]}" + ) + assert entry not in result.exports, ( + f"{entry!r} survived the refusal and is still exported " + f"(exports={result.exports}) — the arm was lowered, not refused" + ) + + +# Every pattern kind that is NOT a wildcard or a binding, over both pair +# spellings. Each entry: (id, scrutinee type, argument, the arm text). +_UNLOWERABLE_ARMS: list[tuple[str, str, str, str]] = [ + ("string-bool", "@String", '"q"', "true -> 100,"), + ("string-int", "@String", '"q"', "1 -> 100,"), + ("array-bool", "@Array", "[1, 2]", "true -> 100,"), + ("array-int", "@Array", "[1, 2]", "1 -> 100,"), + ("string-string", "@String", '"q"', '"yes" -> 100,'), +] + + +class TestPairScrutineeGuardIsAWhitelist: + """Only a wildcard or a binding pattern lowers over a pair scrutinee. + + The first cut of this guard was a BLACKLIST — it named + ``ConstructorPattern`` and ``NullaryPattern`` — and a pair has no + comparable scalar word either, so the literal patterns fell straight + through it into the arm-condition emitter. That was strictly worse + than the bug being fixed: at the branch point ``match @String.0 { true + -> 100, _ -> 200 }`` was a loud WAT failure, and under the blacklist it + became **check-green, exit 0, printing 100** — the scrutinee's heap + pointer used as the truthiness condition. The ``1 ->`` twin was the + late-failure variant: ``vera compile`` exited 0 and shipped a ``.wasm`` + that died at instantiation with no diagnostic and no E-code. + + So the guard is a whitelist, and these cells are what makes it one. + """ + + @pytest.mark.parametrize( + "scrutinee,arg,arm", [c[1:] for c in _UNLOWERABLE_ARMS], + ids=[c[0] for c in _UNLOWERABLE_ARMS], + ) + def test_unlowerable_arm_is_refused( + self, scrutinee: str, arg: str, arm: str, + ) -> None: + source = f"""\ +private fn probe({scrutinee} -> @Int) + requires(true) + ensures(@Int.result >= 0) + effects(pure) +{{ + match {scrutinee}.0 {{ + {arm} + _ -> 200 + }} +}} + +public fn main(@Unit -> @Int) + requires(true) + ensures(@Int.result >= 0) + effects(pure) +{{ + probe({arg}) +}} +""" + _assert_refused(source, arm) + + +class TestIssueRepro: + """The issue's programs: no invalid WAT, and a located refusal. + + ``Some``/``None`` over an ``Array`` is not a lowerable match — + a pair has no tag word. What must not happen is what happened at the + branch point: an ``i32_pair`` local that stops the whole module from + assembling, taking every other function with it. + """ + + _KEY_COUNT = ("Some(@Array) -> array_length(@Array.0),", """\ +public fn key_count(@Json -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + match json_keys(@Json.0) { + Some(@Array) -> array_length(@Array.0), + None -> 0 + } +} +""") + + _KEY_BLOB = ("Some(@Array) -> string_join(@Array.0, \",\"),", """\ +public fn key_blob(@Json -> @String) + requires(true) + ensures(true) + effects(pure) +{ + match json_keys(@Json.0) { + Some(@Array) -> string_join(@Array.0, ","), + None -> "" + } +} +""") + + # The nullary arm FIRST. Both programs above lead with ``Some``, so the + # loop hit a ConstructorPattern before it ever reached the ``None``, and + # the NullaryPattern half of the guard was droppable green. This cell + # is the one that kills that mutation. + _NULLARY_FIRST = ("None -> 0,", """\ +public fn key_count(@Json -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + match json_keys(@Json.0) { + None -> 0, + Some(@Array) -> array_length(@Array.0) + } +} +""") + + @pytest.mark.parametrize( + "arm,source", + [_KEY_COUNT, _KEY_BLOB, _NULLARY_FIRST], + ids=["array_length", "string_join", "nullary_arm_first"], + ) + def test_module_assembles_and_the_skip_is_located( + self, arm: str, source: str, + ) -> None: + entry = "key_blob" if "key_blob" in source else "key_count" + _assert_refused(source, arm, entry=entry) + + +def _push_pattern(local: str = r"\d+") -> re.Pattern[str]: + """The full ``gc_shadow_push`` idiom — push AND shadow-pointer advance. + + Same shape ``test_codegen_gc_rooting.py`` pins: without the advance + every later push overwrites one slot, so both halves are matched in + order. + """ + return re.compile( + r"global\.get \$gc_sp\s+" + rf"local\.get ({local})\s+" + r"i32\.store\s+" + r"global\.get \$gc_sp\s+" + r"i32\.const 4\s+" + r"i32\.add\s+" + r"global\.set \$gc_sp", + re.MULTILINE, + ) + + +_SET_RE = re.compile(r"^\s*local\.set (\d+)\s*$") +_GET_RE = re.compile(r"^\s*local\.get (\d+)\s*$") + + +def _pair_locals(wat_body: str) -> set[tuple[int, int]]: + """Every ``(ptr_local, len_local)`` pair the emitted body reveals. + + Two idioms carry a pair, and both are read rather than assumed: + + * the STACK-POP save — ``local.set L`` then ``local.set P`` on adjacent + lines. A pair is pushed pointer-first, so the length pops first and + the SECOND ``local.set`` is the pointer; + * the COPY — ``local.get X; local.set P; local.get X+1; local.set L``, + which is how a binding takes its own two locals from the scrutinee's + consecutive pair. + + Deriving the roles from the instruction order is what makes the rooting + assertion independent of local NUMBERING, which is what the weaker + version of that test was accidentally relying on. + """ + lines = wat_body.splitlines() + pairs: set[tuple[int, int]] = set() + for i in range(len(lines) - 1): + first, second = _SET_RE.match(lines[i]), _SET_RE.match(lines[i + 1]) + if first and second: + pairs.add((int(second.group(1)), int(first.group(1)))) + for i in range(len(lines) - 3): + g1, s1 = _GET_RE.match(lines[i]), _SET_RE.match(lines[i + 1]) + g2, s2 = _GET_RE.match(lines[i + 2]), _SET_RE.match(lines[i + 3]) + if g1 and s1 and g2 and s2 and int(g2.group(1)) == int(g1.group(1)) + 1: + pairs.add((int(s1.group(1)), int(s2.group(1)))) + return pairs + + +class TestPairScrutineeRooting: + """The pointer half of a pair scrutinee is shadow-pushed — as EMISSION. + + Deliberately a WAT assertion and not a behavioural one. Deleting both + pushes leaves the whole suite green, the GC rooting and reclamation + suites green, and four hostile allocate-inside-the-arm probes green + under ``VERA_EAGER_GC=1``: nothing distinguishes them at run time + today. They are defensive depth — the same #705 discipline + ``_destructure_let`` and ``_extract_constructor_fields`` already follow + for a pointer that becomes invisible to the conservative scan the + moment it lives only in a WASM local — so what these tests can honestly + pin is that the emission is there and complete, not that a program + observes it. Asserting it any other way would be asserting a + difference no probe has produced. + """ + + _SOURCE = """\ +public fn f(@String -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + match @String.0 { + @String -> string_length(string_concat(@String.0, "z")) + } +} +""" + + # The same body with the match removed. Everything else — the parameter + # prologue's own rooting, the `string_concat` intermediates — is + # identical, so the DIFFERENCE is exactly the two pushes this fix adds. + _CONTROL = """\ +public fn f(@String -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + string_length(string_concat(@String.0, "z")) +} +""" + + def test_the_match_adds_exactly_two_pushes_over_the_no_match_control( + self, + ) -> None: + """A differential, not an absolute count. + + An absolute number would pin whatever the surrounding lowering + happens to root as well (four, here) and would move for reasons + that have nothing to do with this fix. The delta against the + match-free twin isolates the scrutinee push and the binder push, + and goes red if either is dropped. + """ + matched = _push_pattern().findall( + wat_fn_body(_compile_ok(self._SOURCE).wat, "f")) + control = _push_pattern().findall( + wat_fn_body(_compile_ok(self._CONTROL).wat, "f")) + assert len(matched) - len(control) == 2, ( + f"match body has {len(matched)} shadow pushes (locals {matched}), " + f"match-free control has {len(control)} (locals {control}); " + f"expected exactly 2 more — the scrutinee's pointer and the " + f"binder's" + ) + + def test_the_pushed_local_is_the_pointer_half_not_the_length(self) -> None: + """Position, from the pairs the WAT itself declares. + + The first version of this test inferred "is a length" from local + NUMBERING — no pushed local may be another's successor — and that + does not check its own name: rooting the length at BOTH sites + pushes ``{0, 3, 5, 10}``, where no element is another's successor, + so the wrong half passed while the delta stayed 2. + + So recover the pairs instead of guessing them. A (ptr, len) pair is + visible in the emitted code as one of exactly two idioms — the + stack-pop save, where the length pops first because the pointer was + pushed first, and the copy, where consecutive source locals are read + in order — and each recovered pair then answers the question + directly: whichever half is rooted must be the pointer. + """ + wat = wat_fn_body(_compile_ok(self._SOURCE).wat, "f") + pushed = {int(m) for m in _push_pattern().findall(wat)} + pairs = _pair_locals(wat) + assert pairs, f"recovered no (ptr, len) pairs from:\n{wat}" + + rooted_wrong = [(p, ln) for p, ln in pairs if ln in pushed] + assert not rooted_wrong, ( + f"a LENGTH half is shadow-rooted: pairs {sorted(rooted_wrong)} " + f"have their length in pushed={sorted(pushed)}. Only the " + f"pointer half is a heap reference; rooting the length roots a " + f"byte count and leaves the buffer unrooted." + ) + rooted_right = [(p, ln) for p, ln in pairs if p in pushed] + assert len(rooted_right) == 2, ( + f"expected exactly 2 pairs rooted by their pointer (the " + f"scrutinee's and the binder's), found {len(rooted_right)}: " + f"pairs={sorted(pairs)} pushed={sorted(pushed)}" + ) + + +class TestControlsThatAlreadyCompiled: + """The issue's two compiling controls, kept as regression guards.""" + + def test_map_keys_used_directly(self) -> None: + source = """\ +public fn n(@Map -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + array_length(map_keys(@Map.0)) +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + n(map_insert(map_new(), "a", 1)) +} +""" + _assert_assembles(source) + assert _run(source, fn="main") == 1 + + def test_json_keys_used_directly(self) -> None: + """``json_keys``'s result was usable at the branch point, and stays so.""" + source = """\ +public fn key_count(@Json -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + array_length(json_keys(@Json.0)) +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + match json_parse("{\\"a\\": 1, \\"b\\": 2}") { + Ok(@Json) -> key_count(@Json.0), + Err(@String) -> 0 - 1 + } +} +""" + _assert_assembles(source) + assert _run(source, fn="main") == 2 + + def test_json_get_array_some_binder(self) -> None: + source = """\ +public fn n(@Json -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + match json_get_array(@Json.0, "xs") { + Some(@Array) -> array_length(@Array.0), + None -> 0 + } +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + match json_parse("{\\"xs\\": [1, 2, 3]}") { + Ok(@Json) -> n(@Json.0), + Err(@String) -> 0 - 1 + } +} +""" + _assert_assembles(source) + assert _run(source, fn="main") == 3 + + +# (payload spelling, the expression that builds one, the fold over the +# binder, the expected value) +_PAYLOADS: list[tuple[str, str, str, int]] = [ + ("Array", "map_keys(map_insert(map_new(), \"a\", 1))", + "array_length(@Array.0)", 1), + ("Array", "[4, 5, 6, 7]", "array_length(@Array.0)", 4), + ("Map", "map_insert(map_new(), \"k\", 9)", + "map_size(@Map.0)", 1), + ("Set", "set_add(set_new(), \"s\")", "set_size(@Set.0)", 1), + ("String", "\"abcde\"", "string_length(@String.0)", 5), + ("Int", "11", "@Int.0", 11), +] + + +class TestOptionAndResultBinderBattery: + """Match binders over ``Option`` / ``Result`` payloads of every shape. + + The scrutinee here is a genuine ADT pointer, so these were green at the + branch point — they are the boundary of the fix, proving the pair-typed + SCRUTINEE change left pair-typed constructor FIELDS alone. + """ + + @pytest.mark.parametrize( + "payload,build,fold,expected", _PAYLOADS, + ids=[p[0] for p in _PAYLOADS], + ) + def test_option_payload( + self, payload: str, build: str, fold: str, expected: int, + ) -> None: + source = f"""\ +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{{ + match Some({build}) {{ + Some(@{payload}) -> {fold}, + None -> 0 - 1 + }} +}} +""" + _assert_assembles(source) + assert _run(source, fn="main") == expected + + @pytest.mark.parametrize( + "payload,build,fold,expected", _PAYLOADS, + ids=[p[0] for p in _PAYLOADS], + ) + def test_result_payload( + self, payload: str, build: str, fold: str, expected: int, + ) -> None: + source = f"""\ +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{{ + match Ok({build}) {{ + Ok(@{payload}) -> {fold}, + Err(@String) -> 0 - 1 + }} +}} +""" + _assert_assembles(source) + assert _run(source, fn="main") == expected + + def test_array_of_json_payload(self) -> None: + source = """\ +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + match json_parse("{\\"xs\\": [1, 2]}") { + Ok(@Json) -> match json_get_array(@Json.0, "xs") { + Some(@Array) -> array_length(@Array.0), + None -> 0 - 1 + }, + Err(@String) -> 0 - 2 + } +} +""" + _assert_assembles(source) + assert _run(source, fn="main") == 2 + + def test_nested_option_of_array_of_string(self) -> None: + source = """\ +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + match Some(Some(map_keys(map_insert(map_new(), "a", 1)))) { + Some(@Option>) -> match @Option>.0 { + Some(@Array) -> array_length(@Array.0), + None -> 0 - 1 + }, + None -> 0 - 2 + } +} +""" + _assert_assembles(source) + assert _run(source, fn="main") == 1 diff --git a/vera/codegen/core.py b/vera/codegen/core.py index 1a59bcd0..9cb90465 100644 --- a/vera/codegen/core.py +++ b/vera/codegen/core.py @@ -2615,6 +2615,30 @@ def _type_expr_to_wasm_type(self, te: ast.TypeExpr) -> str | None: Returns None for Unit, "unsupported" for non-compilable types, "i32_pair" for types represented as (i32, i32) pairs (String, Array). + + The BRANCH ORDER is the checker's, not a convenience ordering + (#1309): ``vera.naming._resolve_named`` resolves a named type as + type parameter (shadowing everything) -> primitive -> alias + (arity-checked) -> declared ADT -> ``Decimal`` -> removed alias -> + opaque ADT, the built-in containers being ABSORBED by that last + branch rather than sitting after it. A WIDTH derived in any other + order disagrees with the type the program was checked and verified + against. This function has no type-parameter step of its own — + monomorphization substitutes concrete arguments before it runs — so + what it must reproduce is the primitive-then-alias-then-ADT spine. + Spec §8.4.1 permits an alias to take a + name the prelude already uses, so ``type Option = Int;`` is a legal + shadow whose parameter must emit i64; codegen used to test + ``_adt_layouts`` (and ``Array`` / ``Map`` / ``Set`` / ``Decimal``, + none of which are ``vera.types.PRIMITIVES``) first and emitted the ADT + pointer's i32 instead. Loud where the widths differ and the target is + a scalar — the module fails WASM validation with ``expected i64, found + i32`` — and SILENT where the target is a pair: the single i32 drops the + length word, the module validates, and ``string_concat`` over a + shadow-aliased ``String`` returned junk bytes at exit 0. Only the + PRIMITIVES ahead of the alias branch may stay ahead of it, because that + is where the checker puts them: ``type Bool = Int;`` leaves ``@Bool`` a + Bool on both sides. """ if isinstance(te, ast.NamedType): name = te.name @@ -2626,22 +2650,10 @@ def _type_expr_to_wasm_type(self, te: ast.TypeExpr) -> str | None: return "i32" if name == "Unit": return None - if name in ("String", "Array"): + if name == "String": return "i32_pair" - if name in ("Map", "Set", "Decimal"): - return "i32" # opaque host handle - # Future is transparent — same representation as T - # (#841: a fused Future> is a - # wrapper pointer, which is repr-compatible with the - # Result pointer; value-typed futures are their value). - # Pre-#841 there was no case here, so a function - # *returning* a Future was E605-skipped. - if name == "Future" and te.type_args and len(te.type_args) == 1: - return self._type_expr_to_wasm_type(te.type_args[0]) - # ADT types compile to i32 (heap pointer) - if name in self._adt_layouts: - return "i32" - # Type aliases — recurse to resolve the underlying type. + # Type aliases — recurse to resolve the underlying type. Ahead of + # every non-primitive branch below, per the checker's order (#1309). # When the alias is parameterised (`type Box = # Array`), substitute the alias's own type params with # the concrete `te.type_args` *before* recursing, so type @@ -2658,6 +2670,21 @@ def _type_expr_to_wasm_type(self, te: ast.TypeExpr) -> str | None: local_subst = dict(zip(alias_params, te.type_args)) alias = substitute_type_vars(alias, local_subst) return self._type_expr_to_wasm_type(alias) + if name == "Array": + return "i32_pair" + if name in ("Map", "Set", "Decimal"): + return "i32" # opaque host handle + # Future is transparent — same representation as T + # (#841: a fused Future> is a + # wrapper pointer, which is repr-compatible with the + # Result pointer; value-typed futures are their value). + # Pre-#841 there was no case here, so a function + # *returning* a Future was E605-skipped. + if name == "Future" and te.type_args and len(te.type_args) == 1: + return self._type_expr_to_wasm_type(te.type_args[0]) + # ADT types compile to i32 (heap pointer) + if name in self._adt_layouts: + return "i32" return "unsupported" if isinstance(te, ast.RefinementType): return self._type_expr_to_wasm_type(te.base_type) diff --git a/vera/wasm/data.py b/vera/wasm/data.py index e806c6a2..6a6aea3b 100644 --- a/vera/wasm/data.py +++ b/vera/wasm/data.py @@ -420,6 +420,15 @@ def _translate_match( Evaluates the scrutinee once, saves to a local, then emits a chained if-else cascade for each arm. + + A PAIR-represented scrutinee (``String`` / ``Array``, #1305) takes + TWO consecutive i32 locals — the same (ptr, len) convention parameters + and constructor fields already use, with the env holding the pointer + half and the length at ``ptr + 1``. ``alloc_local("i32_pair")`` would + otherwise write the internal pseudo-type verbatim into the locals + declaration (``(local $l1 i32_pair)``), which is not a WAT value type, + so the whole module failed to assemble — on programs as ordinary as + ``match @String.0 { @String -> string_length(@String.0) }``. """ # Translate scrutinee scr_instrs = self.translate_expr(expr.scrutinee, env) @@ -441,9 +450,53 @@ def _translate_match( raise CodegenSkip(expr, "match expression has no arms") # Save scrutinee to a local - scr_local = self.alloc_local(scr_wasm_type) instructions: list[str] = list(scr_instrs) - instructions.append(f"local.set {scr_local}") + if scr_wasm_type == "i32_pair": + # A WHITELIST, deliberately: exactly two pattern kinds have a + # lowering over a pair, and every other kind must be refused + # here rather than reach an emitter that will read one of the + # two words as something it is not. A blacklist naming the + # constructor kinds was the first cut of this guard and was + # strictly worse than the bug it was added beside — a pair has + # no comparable scalar word either, so `true ->` and `1 ->` + # fell through into the arm-condition emitter and compiled the + # scrutinee's heap POINTER as the condition: `match @String.0 { + # true -> 100, _ -> 200 }` went from a loud WAT failure to a + # check-green program that exits 0 and prints 100, and the + # integer twin shipped a `.wasm` that died at instantiation + # with no diagnostic at all. Enumerating what IS lowerable + # cannot fail that way when a pattern kind is added. + for arm in expr.arms: + if not isinstance( + arm.pattern, + (ast.WildcardPattern, ast.BindingPattern), + ): + raise CodegenSkip( + arm.pattern, + "pattern over a scrutinee whose representation is a " + "(ptr, len) pair — only a wildcard or a binding " + "pattern lowers over one, since a pair carries " + "neither a constructor tag nor a comparable scalar " + "word", + ) + ptr_local = self.alloc_local("i32") + len_local = self.alloc_local("i32") # consecutive: ptr + 1 + instructions.append(f"local.set {len_local}") + instructions.append(f"local.set {ptr_local}") + # Root the pointer half, following the #705 discipline the + # pair-field extraction below and `_destructure_let` already + # apply to a pointer that lives only in a WASM local. This is + # defensive depth, not a fix for an observed reclamation: with + # both pushes deleted the whole suite, the GC rooting and + # reclamation suites, and four allocate-inside-the-arm probes + # under VERA_EAGER_GC=1 all stay green. The length is not a + # pointer and is deliberately not rooted. + self.needs_alloc = True + instructions.extend(gc_shadow_push(ptr_local)) + scr_local = ptr_local + else: + scr_local = self.alloc_local(scr_wasm_type) + instructions.append(f"local.set {scr_local}") # Infer result type of the match result_type = self._infer_match_result_type(expr) @@ -807,6 +860,25 @@ def _setup_match_arm_env( pattern, "binding pattern type has no slot name", ) + if scr_wasm_type == "i32_pair": + # #1305: a pair scrutinee lives in two consecutive locals + # (``scr_local`` = ptr, ``scr_local + 1`` = len), so the + # binding takes two of its own. Copying only the pointer + # would bind a length-free String and read garbage. The + # push below is the same defensive rooting as the + # scrutinee's — pinned as EMISSION by a WAT differential, + # because no probe distinguishes it behaviourally. + ptr_local = self.alloc_local("i32") + len_local = self.alloc_local("i32") # consecutive: ptr + 1 + instrs = [ + f"local.get {scr_local}", + f"local.set {ptr_local}", + f"local.get {scr_local + 1}", + f"local.set {len_local}", + ] + self.needs_alloc = True + instrs.extend(gc_shadow_push(ptr_local)) + return (instrs, env.push(type_name, ptr_local)) local_idx = self.alloc_local(scr_wasm_type) bind_val = [f"local.get {scr_local}"] # #747: runtime-guard a top-level `match { @Nat -> ... }` From 6ce5e27e3fdf650b0248b3e78ac60c33981dd757 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 17:42:07 +0100 Subject: [PATCH 45/69] Close CR wave 1: a third consumer of the branch-order defect, and two counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_return_type_is_string` decides whether `vera run` decodes a function's (ptr, len) result as UTF-8, and it tested the representation-transparent `Future` strip before the alias table. `Future` is an ADT name, not one of `vera.types.PRIMITIVES`, so an alias of that name shadows it — the same ordering the #1309 fix corrected in `_type_expr_to_wasm_type`, one function over. The review raised it as a hypothetical, so expressibility was measured first rather than assumed. A parameterised alias is expressible and may take a prelude ADT's name: `type Future = Array;` checks clean, and a `@Future` return under it lands in `fn_string_returns` while the width derivation resolves the alias and lowers an `Array`. `vera run` then decodes the array's backing bytes as text and prints two NULs where the byte-identical program under a non-ADT alias name prints the pointer. The non-generic spelling (`type Future = Array;`) does NOT misclassify — the strip requires a type argument — so the parameterised form is the whole of it. Both readings measure identically at this PR's branch point, so the defect is pre-existing rather than introduced by the reorder. `String` keeps its place ahead of the alias branch, being the one primitive the function tests; after the move no other pre-alias branch remains. Three over-correction controls hold the behaviour the `Future` strip exists for: the #841/#1047 transparent `Future` return, PR #1041's alias-to-`Future` shape, and a genuine `@String` return declared alongside the shadowing alias. TESTING's conformance row and dual-target sentence are re-derived rather than edited: the row's prose count comes from the doc-counts oracle (231), and the dual-target split is read off a run of the differential itself — 170 run-level programs, 119 dual-tested, 51 skipping loudly (44 host family, 6 with no zero-argument `main`, 1 nondeterministic). The review relayed 120 dual-tested; the measured figure is 119, and 119 + 51 = 170 closes against the level table. Co-Authored-By: Claude --- AGENTS.md | 8 +- CHANGELOG.md | 2 + CLAUDE.md | 8 +- FAQ.md | 4 +- README.md | 2 +- ROADMAP.md | 2 +- SKILL.md | 2 +- TESTING.md | 32 +++---- docs/SKILL.md | 2 +- docs/index.html | 2 +- docs/index.md | 2 +- docs/llms-full.txt | 14 +-- docs/llms.txt | 2 +- .../test_codegen_alias_adt_name_width_1309.py | 90 +++++++++++++++++++ vera/README.md | 2 +- vera/codegen/core.py | 36 +++++--- 16 files changed, 158 insertions(+), 52 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bf583d7e..7e8890af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Read `SKILL.md` for the full language reference. It covers syntax, slot referenc ### Conformance programs as reference -The conformance suite in `tests/conformance/` contains 239 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-seven negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) instead must *fail* with the E-code in their `expected_error` field, at the stage their `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. +The conformance suite in `tests/conformance/` contains 241 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-seven negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) instead must *fail* with the E-code in their `expected_error` field, at the stage their `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. ### Workflow @@ -187,9 +187,9 @@ Each stage is a module with a single public API function (`parse_file`, `transfo pytest tests/ -v # Run all tests (see TESTING.md) pytest tests/test_conformance.py -v # Conformance suite only mypy vera/ # Type-check the compiler -python scripts/check_conformance.py # All 239 conformance programs hold (positives pass; negatives fail with their E-code) +python scripts/check_conformance.py # All 241 conformance programs hold (positives pass; negatives fail with their E-code) python scripts/check_examples.py # All 42 examples must pass -python scripts/check_corpus_canonical.py # All 288 corpus programs in canonical form +python scripts/check_corpus_canonical.py # All 290 corpus programs in canonical form ``` Test helpers follow a pattern: `_check_ok(source)` / `_check_err(source, match)` / `_verify_ok(source)` / `_verify_err(source, match)`. See existing tests for examples. @@ -198,7 +198,7 @@ When implementing a new language feature, write the conformance program *first* ### Invariants -- All 239 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first +- All 241 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first - All 42 examples in `examples/` must pass `vera check` and `vera verify` - `mypy vera/` must be clean - `pytest tests/ -v` must pass diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ae0bff4..c151d375 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **A `match` on a `String` or `Array` scrutinee compiles** ([#1305](https://github.com/aallan/vera/issues/1305)). `_translate_match` saved the scrutinee into one local at its inferred WAT type, and a pair-represented scrutinee infers `i32_pair` — the internal two-word spelling, not a value type — so the module carried `(local $l1 i32_pair)` and never assembled. The scrutinee and any binding pattern over it now take two consecutive i32 locals, the same (ptr, len) convention parameters and constructor fields already use. Programs as ordinary as `match @String.0 { @String -> string_length(@String.0) }` and `match @Array.0 { @Array -> array_length(@Array.0) }` were check-green and failed `vera compile` at the branch point; both run now. The issue reached this through `json_keys` and framed it as an `Option>` payload binder, which measurement does not support: `json_keys` returns `Array` (see `vera/environment.py` and the prelude body), so its result was never a binder problem and `array_length(json_keys(j))` compiled and ran throughout — the trigger is the scrutinee's representation, with nothing JSON-specific about it. The issue's own repro additionally matches `Some` / `None` against that array; a pair carries no constructor tag, so codegen now refuses that arm with an `E602` naming it, at the pattern's own location, instead of emitting a local that stops the whole module from assembling. That the checker accepts such a match over a constructor-less container ADT at all is filed separately as [#1315](https://github.com/aallan/vera/issues/1315). +- **A `Future`-named type alias no longer makes an array return print as text.** `_return_type_is_string` — which decides whether `vera run` decodes a function's (ptr, len) result as UTF-8 — tested the representation-transparent `Future` strip *before* the alias table, and `Future` is an ADT name rather than one of `vera.types.PRIMITIVES`, so an alias of that name shadows it. Under `type Future = Array;` a `@Future` return was therefore classified a string while the width derivation resolved the alias and lowered an `Array`, and the array's backing bytes were decoded as text: two NULs where the same program under a non-ADT alias name printed the pointer. This is the third consumer of the branch-order defect [#1309](https://github.com/aallan/vera/issues/1309) fixed in `_type_expr_to_wasm_type`, found by review on the PR rather than by the original issue, and it misclassifies identically at that PR's branch point — pre-existing, not introduced by the reorder. `String` keeps its place ahead of the alias branch, being the one primitive involved, and the #841/#1047 transparent-`Future` decode and PR #1041's alias-to-`Future` shape are held by over-correction controls. + - **The `ch09_json` conformance entry cites the section that exists.** Its manifest `spec_ref` read `Section 9.4.4`, a section Chapter 9 does not have — the chapter's `9.4` runs to `9.4.3 Map` and `Json` is `9.7.1`. - **A user-defined `fn get` / `fn put` is no longer hijacked by an enclosing handler** ([#1284](https://github.com/aallan/vera/issues/1284)). Three sites answered "does this `get` mean the user's declaration or the effect operation?" independently. The checker answers user-fn-first — `_check_call_with_args` looks a bare name up as a function before it looks it up as an operation, so a declaration named `get` owns every bare `get(...)` in its scope, which an arity or argument-type error at such a call site proves by reporting the *user's* signature (E201/E202). Codegen answered twice more: the declared-effect row in `vera/codegen/functions.py` withheld the intrinsic when `_fn_sigs` already owned the name, and the handler expression in `vera/wasm/calls_handlers.py` installed `get`/`put` unconditionally. From `vera check`-green source that produced, depending on the nesting shape, a **silently wrong value** (`nat_to_int(get(3))` under `handle[State](@Int = 5)` returned the cell's 5 for the function's 4, and the argument was not even emitted), a **module WASM validation rejects** (a `@Bool`-returning user `get` took `state_get_Int`'s i64 into an `i32` position; different-family nesting took the *enclosing* cell's getter at the wrong width), or a **spurious `[E602]`** in which the #1233 unaddressable-cell gate refused `main` outright, naming "a bare or qualified State operation `get`" the program never contained. The repair is one predicate, `vera.slots.bare_call_denotes_user_fn`, stating the checker's rule once and consumed by the bare-call dispatch in `vera/wasm/calls.py` (which now gates the clause-inline registry, the host-cell intrinsics and the addressability gate together), by the three bare-`FnCall` result-type inference sites, and by the monomorphizer's discovery walk — each passing its own name table, so the sites cannot answer differently about the table they share. Gating the *dispatch* rather than the *registries* is what makes it correct rather than merely consistent: the registries record which cell an op name reaches, which is true whatever the program's declarations are called, and withholding an entry answered both questions with one table. That is why the gate-only fix measured during PR #1283's review turned the loud skip into a differently-broken module, and why it also cost the qualified spelling its cell — `State.put(5)` in a function that also declares `fn put` compiled to `call $vera.put` and failed to link, which now lowers to the intrinsic the checker always meant. Discovery's `MonoContext.fn_names` moves to the same lookup-time question, so a `get(())` fixing a generic's type variable under a handler names the clone the rewrite emits. All 256 conformance and example programs emit byte-identical WAT. **The `W002` async-commutativity warning was the same defect in the checker's own file** and is corrected with them: `_collect_expr_effects` asked `lookup_effect_op` before the scoped function lookup — the last op-first consumer — so a user function named after an operation contributed the *operation's* parent effect to the commutativity analysis instead of its own declared row, wrong in both directions. A **pure** `fn get`, in a program containing no `State` at all, drew `async argument performs State effects`; a `fn get` that performs `IO`, under a row naming `Http` first, drew **no warning**, because the walk bound the name to `Http.get`, which is inside the commutative whitelist, and silently withheld the eager-evaluation warning the program is owed. Both are pinned with rename controls — the byte-identical program with the helper called `gett` / `fetch` was correct throughout — beside a control that an unshadowed bare `get(())` under a `State` row still warns, so the fix cannot degenerate into never reporting `State`. The walk's comment claiming it resolves "like the call checker above" is now true. One caveat the predicate did not close on its own: codegen's name table was not scope-accurate, so a name the call site cannot see still answered "user-owned" there — a property of the table rather than of the rule, closed by [#1299](https://github.com/aallan/vera/issues/1299) below. diff --git a/CLAUDE.md b/CLAUDE.md index 0ed48c07..6c6ce211 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,10 +59,10 @@ VERA_EAGER_GC=1 vera run file.vera # Force GC on every alloc (see ENVIRONMENT.m VERA_DEBUG_HOST_ERRORS=1 vera run file.vera # Re-raise a host callback's own exception (see ENVIRONMENT.md, debug knob for host-binding bugs) mypy vera/ # Type-check the compiler itself -python scripts/check_conformance.py # Verify all 239 conformance programs (positives pass their level; negatives fail with their expected_error E-code) +python scripts/check_conformance.py # Verify all 241 conformance programs (positives pass their level; negatives fail with their expected_error E-code) python scripts/check_examples.py # Verify all 42 examples parse + check + verify python scripts/check_examples_run.py # Run every runnable example trap-free under the native runtime; the rest carry a documented skip property, and an example that is neither is an error -python scripts/check_corpus_canonical.py # Verify all 288 corpus programs are in canonical form (vera fmt) +python scripts/check_corpus_canonical.py # Verify all 290 corpus programs are in canonical form (vera fmt) python scripts/check_examples_readme.py # Verify vera run commands in examples/README.md python scripts/check_spec_examples.py # Verify spec code blocks parse python scripts/check_readme_examples.py # Verify README code blocks parse @@ -94,7 +94,7 @@ See [`TOOLCHAIN.md`](TOOLCHAIN.md) for the CLI cookbook — driving the toolchai - `vera/` — Reference compiler: grammar, parser, AST, transformer, type checker, verifier, codegen, CLI - `examples/` — 42 example Vera programs (all must pass `vera check` and `vera verify`) - `tests/` — Test suite (unit tests + conformance suite) -- `tests/conformance/` — 239 conformance programs validating every language feature against the spec +- `tests/conformance/` — 241 conformance programs validating every language feature against the spec - `scripts/` — CI and validation scripts ## Writing Vera code @@ -131,7 +131,7 @@ Before changing code — **adding or removing** — write the test that proves y ## What not to break - Pre-commit hooks run mypy + pytest + conformance suite + example validation on every commit -- All 239 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first +- All 241 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first - All 42 examples in `examples/` must pass `vera check` and `vera verify` - Version must stay in sync across `pyproject.toml`, `vera/__init__.py`, `docs/index.html`, `README.md`, and `uv.lock` (gated by `scripts/check_version_sync.py`); CHANGELOG.md must also carry a matching `## [X.Y.Z]` section - All tests must pass: `pytest tests/ -v` diff --git a/FAQ.md b/FAQ.md index 167bbbe0..cb6f8aae 100644 --- a/FAQ.md +++ b/FAQ.md @@ -236,7 +236,7 @@ None of this is Vera-specific, but it validates the design choices. The thesis i This is a real concern. LLMs are trained on trillions of tokens of Python, TypeScript, and JavaScript. A MojoBench study (NAACL 2025) found that even fine-tuned models achieved only 30–35% improvement over base models on Mojo code generation, illustrating the cold-start problem for new languages. -Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (239 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification. +Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (241 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification. ## How does Vera compare to Dafny / Lean / Koka / F*? @@ -279,7 +279,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 11,434 tests, including a 239-program conformance suite +- 11,600 tests, including a 241-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/README.md b/README.md index bcdf2061..15a5d958 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ cp /path/to/vera/SKILL.md ~/.claude/skills/vera-language/SKILL.md ## Project status -Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 11,434 tests, 95% Python code coverage, 238 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. +Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 11,600 tests, 95% Python code coverage, 241 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. The reference compiler — parser, AST, type checker, contract verifier (Z3), WASM code generator, module system, browser runtime, and runtime contract insertion — is working. The language specification is in draft across [14 chapters](spec/). diff --git a/ROADMAP.md b/ROADMAP.md index dc8add0b..c3df6a0f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,7 +12,7 @@ Ordering derives from the design principles ([DESIGN.md](DESIGN.md)): verificati ## Where we are -11,434 tests, 239 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. +11,600 tests, 241 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. ## Stage 19 — The verification completeness sprint diff --git a/SKILL.md b/SKILL.md index 9fd95242..ef409117 100644 --- a/SKILL.md +++ b/SKILL.md @@ -2461,7 +2461,7 @@ public fn main(@Unit -> @Unit) ## Conformance Suite -The `tests/conformance/` directory contains 239 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. +The `tests/conformance/` directory contains 241 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. Each program is organized by spec chapter (`ch01_int_literals.vera`, `ch04_match_basic.vera`, `ch07_state_handler.vera`, etc.) and the `manifest.json` file maps features to programs. When you need to see how a specific construct works, check the conformance program before reading the spec. diff --git a/TESTING.md b/TESTING.md index e9488eb1..d01305b2 100644 --- a/TESTING.md +++ b/TESTING.md @@ -6,9 +6,9 @@ This is the single source of truth for Vera's testing infrastructure, coverage d | Metric | Value | |--------|-------| -| **Tests** | 11,434 across 171 files (~155,000 lines of test code; 11,238 passed + 26 stress, 170 skipped) | +| **Tests** | 11,600 across 173 files (~155,000 lines of test code; 11,403 passed + 26 stress, 171 skipped) | | **Compiler code coverage** | 95% Python, 87% JavaScript (CI minimum: 80%) | -| **Conformance programs** | 239 programs across 9 spec chapters, validating every language feature | +| **Conformance programs** | 241 programs across 9 spec chapters, validating every language feature | | **Example programs** | 42, all validated through `vera check` + `vera verify` | | **Spec code blocks** | 189 parseable blocks from 14 spec chapters: 92 parse, 86 type-check, 85 verify (the rest carry inline `vera:skip` annotations, #538) | | **README code blocks** | 4 Vera blocks (4 validated, 0 annotated) | @@ -43,7 +43,7 @@ pytest tests/test_runtime_traps.py::TestHostErrorDebugKnob1302 -v mypy vera/ # strict mode # Validation scripts -python scripts/check_conformance.py # conformance suite (239 programs, see manifest.json) +python scripts/check_conformance.py # conformance suite (241 programs, see manifest.json) python scripts/check_examples.py # 42 example programs python scripts/check_spec_examples.py # spec code blocks python scripts/check_readme_examples.py # README code blocks @@ -104,7 +104,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_checker_errors.py` | 73 | 1,196 | Error codes, resolution-coverage diagnostics, contracts, error accumulation (#420 split); cyclic type aliases incl. #1059 self-reference through a type argument (`Future`, mutual `Future`/`Future`, `Array`) rejected E132 | | `test_checker_builtins_collections.py` | 97 | 848 | Map / Set / Decimal / Json / Html / Http / Inference built-in type-checking (#420 split) | | `test_checker_builtins_strings.py` | 122 | 945 | String / numeric / type-conversion / float-predicate / string-search / markdown / regex built-in type-checking, removed-legacy-name regression (#420 split) | -| `test_obligations.py` | 760 | 1,741 | Reified proof obligations + warm `VerificationSession` (#222 Phase A): full-corpus differential oracle (warm session == cold `verify()` on diagnostics, summary, and obligation stream, plus warm-twice determinism, across all 42 examples and every verify/run-level conformance program), summary↔obligation tier-bookkeeping consistency (including the #967 `total == tier1_verified + tier3_runtime` leg, plus a focused self-consistency pin on the three call-demotion examples), the #1242 stream partition — over a corpus widened to every conformance program that type-checks, at any level, `len(obligations) == total + violated + tier3_unguarded` and every status is one of the documented five, with the vocabulary read from the `ObligationStatus` Literal so a sixth member fails rather than vanishing from the counts — per-kind unit tests (requires / ensures / decreases / nat_sub / call_pre statuses, counterexamples, error codes), content-key stability + same-text-two-sites span disambiguation, session solver reuse, type-error short-circuit, ADT-registry resync between programs; plus the Phase B incremental suite — identical-source full replay, callee-body-edit replays callers while callee-contract-edit invalidates them, span-shift and ADT-edit conservative invalidation, cross-program isolation, timeout-status never cached (monkeypatched solver), FIFO eviction bound; plus the #727 dedup pin — a violating call in a let RHS records exactly one E501 diagnostic and one call_pre obligation; plus the #1208 call-site rendering pin — a PARAMETERISED callee slot substitutes into the E501 message and its fix instead of falling back to the generic wording | +| `test_obligations.py` | 766 | 1,741 | Reified proof obligations + warm `VerificationSession` (#222 Phase A): full-corpus differential oracle (warm session == cold `verify()` on diagnostics, summary, and obligation stream, plus warm-twice determinism, across all 42 examples and every verify/run-level conformance program), summary↔obligation tier-bookkeeping consistency (including the #967 `total == tier1_verified + tier3_runtime` leg, plus a focused self-consistency pin on the three call-demotion examples), the #1242 stream partition — over a corpus widened to every conformance program that type-checks, at any level, `len(obligations) == total + violated + tier3_unguarded` and every status is one of the documented five, with the vocabulary read from the `ObligationStatus` Literal so a sixth member fails rather than vanishing from the counts — per-kind unit tests (requires / ensures / decreases / nat_sub / call_pre statuses, counterexamples, error codes), content-key stability + same-text-two-sites span disambiguation, session solver reuse, type-error short-circuit, ADT-registry resync between programs; plus the Phase B incremental suite — identical-source full replay, callee-body-edit replays callers while callee-contract-edit invalidates them, span-shift and ADT-edit conservative invalidation, cross-program isolation, timeout-status never cached (monkeypatched solver), FIFO eviction bound; plus the #727 dedup pin — a violating call in a let RHS records exactly one E501 diagnostic and one call_pre obligation; plus the #1208 call-site rendering pin — a PARAMETERISED callee slot substitutes into the E501 message and its fix instead of falling back to the generic wording | | `test_verifier_contracts.py` | 95 | 898 | Z3 verification over the example corpus, trivial/ensures/if-else/let/multi-clause contracts, counterexamples, tier classification, arithmetic, verification summaries, Diverge effect, edge cases, string-length + string-predicate verification (#839 split) | | `test_verifier_nat_obligations.py` | 82 | 1,743 | **`@Nat` subtraction underflow obligation** (#520 — Path-A discharge via requires/path-conditions/path-aware Z3 refutation, pure-literal exclusion, Int-Int and Nat-Int exemptions) and **`@Nat` binding-site narrowing obligation** (#552/#747/#749 — Tier-1 `value >= 0` at let/call-arg/effect-op-arg/ctor-field/match-bind/destructure narrowing — a concrete site classifies `tier3_runtime` (codegen-guarded) while the effect-op argument and generic-instantiated constructor field classify `E504` (obligated but unguarded, #754/#757) whose rationale names its actual cause — an untranslatable value — rather than the untranslatable-or-timeout conflation #1251 removed, walker-recursion pins, `_narrows_into_nat` verifier/codegen soundness parity; PR #972 clone-instantiated side-table substitution — a `Some(@T)` bind in an `Option`-instantiated clone is no narrowing, genuine clone-path narrowings still obligated); #1201 — a builtin `Tuple` parameter's match-bound components carry their declared component facts (a valid ensures over one proves instead of falsely violating) and an `Int` component bound as `@Nat` fires one loud `E503` per component, both mutation-caught (#839 split) | | `test_verifier_primitive_ops.py` | 39 | 662 | **Primitive-operation safety obligations** (#680) — division/modulo by-zero `E526` and array-index-bounds `E527`, the in-bounds/out-of-bounds two-check with float-exemption, honest Tier-3 for opaque lengths, off-by-one and lower-bound pins, De Bruijn-correct fix hints (#839 split) | @@ -122,7 +122,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_soundness_392.py` | 36 | 584 | #392 audit batches 1–2 — verifier soundness/completeness fixes: signed div/mod truncate toward zero (#799), body `assert(P)` carries a Tier-1 obligation (#800), divisions in contract predicates carry a `div_zero` obligation (#801), and the #804 assume-half of #800's `assert` rule — a prior `assert`/`assume` discharges later obligations (including a later call's precondition) + the postcondition at Tier 1, removing false E501/E503/E500/E505 | | `test_int_overflow.py` | 6 | 143 | #798 — `@Int`/`@Nat` arithmetic-overflow obligations (part of the #392 `smt.py` soundness audit): `+`/`-`/`*` on `@Int`/`@Nat` now emit an `int_overflow` obligation (the analog of `nat_sub`/`div_zero`) rather than modelling the operands as Z3's unbounded integers, so a `ensures(@Int.result > @Int.0)` over `@Int.0 + 1` no longer proves a contract the i64/u64 runtime violates under two's-complement wraparound. Unbounded operands leave the obligation undischarged (Tier-3, runtime-guarded); operand bounds that prove the result stays in range discharge it at Tier 1 | | `test_int_overflow_codegen.py` | 62 | 718 | #798 Stage 3 — runtime overflow-trap codegen: the codegen emits a guard at *exactly* the `@Int`/`@Nat` `+`/`-`/`*` sites the verifier obligates, so `vera run`/`vera compile` programs trap on overflow instead of silently wrapping at the i64/u64 boundary. #808 wired the guard to the `vera.overflow_trap` host import, so the trap now classifies `kind="overflow"` (carrying the overflow Fix paragraph) rather than the generic `unreachable`; `TestOverflowTrapKind808` pins that, with controls proving the #520 `nat_sub` underflow and #813 `@Nat`→`@Int` widen guards still classify `unreachable` | -| `test_int_overflow_differential.py` | 254 | 398 | #798 Stage 3 verifier↔codegen classification differential (cross-component soundness rule): the codegen overflow guard must fire at exactly the sites the verifier obligates *and* classify each site's operand type (`@Int` i64 vs `@Nat` u64) identically — else a Tier-1-clean program traps spuriously or a wrapping op slips through unguarded. Over a corpus exercising all five operand combos plus the literal-left ambiguity (a naive codegen mis-classifies it as `@Nat`), asserts the verifier's per-site gated classification equals the codegen's site for site, both sides driven by the same `ast.span_key` | +| `test_int_overflow_differential.py` | 256 | 398 | #798 Stage 3 verifier↔codegen classification differential (cross-component soundness rule): the codegen overflow guard must fire at exactly the sites the verifier obligates *and* classify each site's operand type (`@Int` i64 vs `@Nat` u64) identically — else a Tier-1-clean program traps spuriously or a wrapping op slips through unguarded. Over a corpus exercising all five operand combos plus the literal-left ambiguity (a naive codegen mis-classifies it as `@Nat`), asserts the verifier's per-site gated classification equals the codegen's site for site, both sides driven by the same `ast.span_key` | | `test_nat_int_widening.py` | 36 | 602 | #813 — `@Nat -> @Int` widening coercion obligation (dual of #552 `nat_bind`, part of the #392 soundness audit): a `@Nat` in (i64.MAX, u64.MAX] reinterprets when widened (u64.MAX → -1), so a `nat_to_int_coerce` obligation that the value is `<= i64.MAX` now fires at the return position — provably-in-range → Tier-1, provably-out-of-range (`@Nat.0 >= 2**63`) → loud E530, unbounded → honest Tier-3 (runtime-guarded), with an `@Int -> @Int` control that must not fire; the unguarded generic-`@Int`-field case also has its `E531` rationale read for WHAT IT SAYS — a value bounded on neither side, not the untranslatable-or-timeout conflation #1251 removed. The #813 follow-up adds the explicit `nat_to_int` built-in and heterogeneous `if`/`match` arms with a non-negative-literal alternative; #820 adds the heterogeneous-`@Int`-slot arm, closure argument, and closure return/capture obligations (each per-arm / per-site, with `@Int`-arm and `@Nat`-formal controls that must not fire) | | `test_int_widening_codegen.py` | 52 | 535 | #813 Stage 3 — runtime `@Nat -> @Int` widening-trap codegen: the codegen emits a guard at *exactly* the `@Nat -> @Int` coercion sites the verifier obligates (return, `let`, call argument, and — since #820 — array element, tuple construction/destructure, heterogeneous `if`/`match` arm, closure argument/return), so `vera run`/`vera compile` programs trap when a `@Nat` above i64.MAX would reinterpret to a negative `@Int` instead of silently returning the wrong value. The trap is a bare `unreachable` (shares `_emit_negative_i64_guard` with the #552 nat-bind guard), classified `kind="unreachable"` today (a dedicated widening trap kind is a follow-up) | | `test_int_widening_differential.py` | 26 | 320 | #813 verifier↔codegen behavioural differential (cross-component soundness rule): at every `@Nat -> @Int` coercion site the verifier's `nat_to_int_coerce` classification must AGREE with the runtime — a `tier3` (codegen-guarded) site MUST trap on a `@Nat` above i64.MAX (return / `let` / call-arg / constructor field / ADT sub-pattern / match-bind, and the #820 array-element / tuple-component / heterogeneous-arm / closure argument-return sites), while a `tier3_unguarded` (E531) site must NOT trap (the generic-instantiated `@Int`-field coercion codegen cannot guard). Runs BOTH sides on one corpus so the "runtime-guarded" claim is checked against the actual trap — catching a verifier deferral codegen never guards (unsound silent -1) or a spurious trap | @@ -151,7 +151,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_dropped_entry_1183_1186.py` | 21 | 561 | A dropped entry function is refused, never silently replaced (#1183), and an imported body's skip locates in its own module (#1186). #1183: the repro — declared `main` dropped, one public sibling surviving — exits nonzero with `main` and the root [E602]/[E620] named, and the sibling's 4243 sentinel (a value no fallback, default, or error path produces) never appears on stdout; the same for an explicit `--fn`, for the `--json` envelope (`ok: false`), and at the `execute()` library boundary; `CompileResult.dropped_fns` is pinned as the reified source of the refusal; the `Compilation notes:` block appears when a sibling survives (the ungating); auto-selection survives for the never-declared case and prints a one-line stderr note naming its choice; zero-export `compile` exits nonzero in both text and JSON; and the browser bundle refuses a dropped `main` using the SURVIVING-sibling fixture, so a non-empty export list rules out the zero-export check as the cause. #1186: the root E602 carries the MODULE's path with module-local line/column and quotes the module's source line, the [E620] cross-file prefix fires with its exact wording derived from the root's own location, a same-file control keeps the bare `at line N, column M` form, and `vera test` names the [E602] root instead of calling a public-but-dropped function private. Mutations — api.py refusal, CLI refusal, the E620 drop record, the module source scope, the tester reason, the notes ungating, the auto-select note, the zero-export gate, the browser refusal — each killed by a named test | | `test_imported_trap_source_map_1189.py` | 8 | 342 | An imported function's runtime trap frame names ITS module's file (#1189), the source-map sibling of #1186's diagnostic fix. Fixtures split the basenames (`chinchilla.vera` module, `stargazer.vera` importer) so a frame's attribution is decidable from the string alone, and every trap is a precondition violation so `WasmTrapError.kind` is pinned. Covers the three doors: an imported non-generic fn (pre-fix `` — never registered on the main generator), a monomorphized clone of an imported generic (pre-fix the IMPORTER's path with the module's line range, which in the fixture names a real-but-unrelated importer function), and the `mod$…` emission of a locally-shadowed import (whose rightmost-`$` strip yields nobody's entry). Asserted at the `cmd_run` text backtrace (per-frame line, never the whole stderr blob — `main` legitimately names the importer), the `--json` frames array, `fn_source_map` itself, and `execute()`'s `WasmTrapError.frames`. Over-correction control: a wholly main-file trap keeps the main file, green before and after. Mutations — the module file on the Pass-0.5 registrar, the bare-name harvest, the mangled-name mirror, the Pass-1.5 module source scope — each killed by a distinct named test | | `test_codegen_typeparam_unit_wildcard_1060.py` | 31 | 959 | Wildcard over a type-parameter field instantiated to `Unit` (#1060), the type-parameter sibling of #1043's declared-`Unit` field: a WILDCARD over `Box` field `T` used to advance the match offset walk by the generic `i32` width, so on `Box` (field erased to 0 bytes) every later field read four bytes high — silently check-green. Bug-manifesting shapes go end-to-end (`Box` trailing-`Int`, `Named` `String` read-back, `Entry` nested-ctor tag, `Bool`-following, second-type-parameter, and a nested-generic `Outer` wrapping `Inner` that exercises the deeper-recursion type substitution); controls stay green (before-erased field, trailing wildcards, `Option`/`Result` builtins, `Box`/`Box`/`Tagged` alignment-coincidence, structural `Eq`/`show` recompute path); the direct-call boundary is pinned (#1065) — a `match mk() { … }` scrutinee recovers its concrete instantiation from the callee's declared return type, so a wildcard followed by a read now compiles and reads the real value (`Box` trailing-`Int`, `Entry` nested-ctor, `Named` `String` read-back) instead of the sound #1060 interim LOUD-skip, while a trailing direct-call wildcard still compiles; the generic-call sibling (#1072) resolves the declared return's type variables from the call site (`P2` at T=Int, the var-typed field at `String` i32_pair width, a fully concrete parameterized return on a generic fn, nested-ctor and `String`-read-back variants, plus a trailing-wildcard control), and the module-call door (#1073) routes `boxlib::mk()` — and the imported-generic #1072 x #1073 compound — through the shared resolver into the same recovery; mutation-validated per arm (reverting the #1060 instantiation-awareness flips exactly the #1060 bug-manifesting shapes RED with declared-`Unit` #1043 tests green; reverting the #1065 declared-return threading flips exactly the three direct-call value shapes RED; neutralizing the #1072 generic arm flips exactly the five generic value shapes + the imported-generic compound RED; neutralizing the #1073 module arm flips exactly the two module tests RED) | -| `test_codegen_alias_adt_name_width_1309.py` | 107 | 328 | A `type` alias whose name is also a registered ADT's must emit the ALIAS TARGET's width (#1309). Codegen's `_type_expr_to_wasm_type` tested `_adt_layouts` — and `Array`/`Map`/`Set`/`Decimal`, none of them primitives — before the alias table, where the checker's `_resolve_named` resolves primitive, then alias, then declared ADT; so `type Option = Int;` emitted the ADT's i32 pointer for an i64 slot on a check-green, verify-green program. Three dispositions are pinned separately because they fail differently: LOUD scalar targets (`Int`/`Nat` i64, `Float64` f64) died at load; PAIR targets are the SILENT ones the issue's "matching widths" prediction missed — an `i32_pair` is two words and the single i32 dropped the length, so `string_concat("ab", "ab")` returned junk bytes and `array_length` over three elements returned 0, both at exit 0; and matching-width targets (`Bool`/`Byte`/`Map`/`Set`/`Decimal`, all i32) are INERT, kept as green-both-sides guards that the reorder leaves them alone. The battery is the differential that makes width-luck unreintroducible: every name in the LIVE built-in ADT registry (read off a real `CodeGenerator`, so a new built-in joins without anyone widening a list) crossed with every representation class, comparing the emitted `twice` signature against the identical program under a fresh alias name, plus the two unit duals — under an alias the derivation answers the target's width, without one it still answers the ADT pointer, so "the alias wins" cannot be satisfied by breaking every ordinary ADT parameter. Primitives are asserted to still shadow a same-named alias — the one branch that must NOT move — across all seven spellings by asking the derivation directly, which is the only way to reach every one of them since two are checker-refused (`@Bool.0 + @Bool.0` is E140, `type Int = Int;` is E132); a run-level program carries the behavioural half, because `type Bool = Int;` used AS a Bool is check-green, runs, and distinguishes the hoist mutant. An earlier draft claimed the checker refuses every program that would exercise this, which is measured false. `Json` and `HtmlNode` are deliberately outside the prelude-ADT row: their prelude combinator bodies render against the flat alias map a main-file shadow pollutes, which is an alias-env SCOPING defect (#1316) the reorder does not reach — though it does MOVE that failure (17 prelude `json_*` signatures flip width, the loader's complaint reverses direction, `html_attr` loses a push), so "fails identically" — an earlier draft's wording — is measured false | +| `test_codegen_alias_adt_name_width_1309.py` | 112 | 418 | A `type` alias whose name is also a registered ADT's must emit the ALIAS TARGET's width (#1309). Codegen's `_type_expr_to_wasm_type` tested `_adt_layouts` — and `Array`/`Map`/`Set`/`Decimal`, none of them primitives — before the alias table, where the checker's `_resolve_named` resolves primitive, then alias, then declared ADT; so `type Option = Int;` emitted the ADT's i32 pointer for an i64 slot on a check-green, verify-green program. Three dispositions are pinned separately because they fail differently: LOUD scalar targets (`Int`/`Nat` i64, `Float64` f64) died at load; PAIR targets are the SILENT ones the issue's "matching widths" prediction missed — an `i32_pair` is two words and the single i32 dropped the length, so `string_concat("ab", "ab")` returned junk bytes and `array_length` over three elements returned 0, both at exit 0; and matching-width targets (`Bool`/`Byte`/`Map`/`Set`/`Decimal`, all i32) are INERT, kept as green-both-sides guards that the reorder leaves them alone. The battery is the differential that makes width-luck unreintroducible: every name in the LIVE built-in ADT registry (read off a real `CodeGenerator`, so a new built-in joins without anyone widening a list) crossed with every representation class, comparing the emitted `twice` signature against the identical program under a fresh alias name, plus the two unit duals — under an alias the derivation answers the target's width, without one it still answers the ADT pointer, so "the alias wins" cannot be satisfied by breaking every ordinary ADT parameter. Primitives are asserted to still shadow a same-named alias — the one branch that must NOT move — across all seven spellings by asking the derivation directly, which is the only way to reach every one of them since two are checker-refused (`@Bool.0 + @Bool.0` is E140, `type Int = Int;` is E132); a run-level program carries the behavioural half, because `type Bool = Int;` used AS a Bool is check-green, runs, and distinguishes the hoist mutant. An earlier draft claimed the checker refuses every program that would exercise this, which is measured false. A separate block covers the THIRD consumer of the same disease (CR on PR #1323): `_return_type_is_string` tested the `Future` transparency strip before the alias table, so under `type Future = Array;` a `@Future` return was classified a string and `execute()` decoded the array's backing bytes as UTF-8 — two NULs where the fresh-name control printed the pointer, measured identically at the branch point and so pre-existing. `String` stays ahead of the alias branch there too, being the one primitive involved, and three over-correction controls hold the #841/#1047 transparent-`Future` decode and PR #1041's alias-to-`Future` shape. `Json` and `HtmlNode` are deliberately outside the prelude-ADT row: their prelude combinator bodies render against the flat alias map a main-file shadow pollutes, which is an alias-env SCOPING defect (#1316) the reorder does not reach — though it does MOVE that failure (17 prelude `json_*` signatures flip width, the loader's complaint reverses direction, `html_attr` loses a push), so "fails identically" — an earlier draft's wording — is measured false | | `test_codegen_pair_scrutinee_1305.py` | 32 | 685 | A `match` whose SCRUTINEE is pair-represented (`String` / `Array`) took one local at the internal `i32_pair` pseudo-type, so the module carried `(local $l1 i32_pair)` and never assembled (#1305). The issue reached it through `json_keys` and framed it as an `Option>` payload binder; the docstring records why measurement does not support that — `json_keys` returns `Array`, and `array_length(json_keys(j))` compiled and ran at the branch point — so the tests are built on the shapes that actually trigger it: `match @String.0 { @String -> … }` and `match @Array.0 { @Array -> … }`, both legal and check-green, plus the slot, call-result and builtin-result scrutinee forms. One test returns the BOUND STRING rather than its length, because a fix that allocated two locals and copied only the pointer passes every length-free assertion. The issue's own repro matches `Some`/`None` against that array; a pair carries no tag, so the assertion is that the module assembles and the refusal is a located E602 — not that the nonsense compiles (the checker accepting it is #1315). The guard is a WHITELIST (wildcard and binding only) and these cells are why: as a blacklist naming the two constructor kinds it let `true ->` and `1 ->` fall through into the arm-condition emitter, turning a loud WAT failure into a check-green program that exits 0 printing 100 from the scrutinee's heap POINTER read as a truth value, and its integer twin into a shipped `.wasm` that died at instantiation with no diagnostic — so five unlowerable-arm cells (bool/int/string literals over both pair spellings) and a nullary-arm-FIRST program pin each half, the last because both original repros led with `Some` and left the nullary half droppable green. The two shadow pushes are pinned as EMISSION by a WAT differential against the match-free twin (exactly two more push idioms) plus a position assertion that no length half is rooted: deleting both pushes leaves the whole suite, the GC rooting and reclamation suites, and four allocate-in-the-arm probes under `VERA_EAGER_GC=1` green, so a behavioural claim would be one no probe supports. The two controls the issue listed as already-compiling are kept as regression guards, and an Option/Result binder battery over `Array`/`Array`/`Array`/`Map`/`Set`/`String`/`Int` payloads plus a nested `Option>>` pins the boundary: the scrutinee change left pair-typed constructor FIELDS alone | | `test_codegen_erased_alias_typeargs_1070.py` | 15 | 398 | Non-literal erases-to-Unit type ARGUMENTS (#1070): `Box` (`type U = Unit;`), `Box>`, `Box`, and alias chains — the #1060 width recomputation's zero-size test was the literal name `Unit`, so these spellings got 4 bytes and every later field read a shifted offset (silent 22→0, nested 314→0); the same literal-test disease made structural `Eq` over the same spellings fall back to the scalar POINTER compare (pre-existing — equal structs compared unequal, silently) and `show`/`hash` loud-skip. Pins every spelling end-to-end: wildcard reads (trailing `Int`, nested ctor), `Eq` equal/distinct + `Future` arg, `show` renders `unit` + the real fields, `hash` payload-sensitive + deterministic, literal-`Unit` controls; the rider — a zero-size `@Unit` BINDING after an unrecoverable wildcard is not a read (compiles), while a genuine read beyond it still LOUD-skips (E602); mutation-validated per site (width fn, field-name canonicalisation, both dispatch gates, derivability gate, rider — each flips exactly its own test subset) | | `test_codegen_alias_typeargs_eq_1076.py` | 34 | 499 | The Eq-dispatch ground-spelling cluster (#1076/#1077/#1078). #1076: structural `==` over NON-Unit alias type args (`Box`/`MyStr`/`MyBool`/`Future`/chains) silently pointer-compared (equal structs → 0, check-green) — equal AND distinct pairs per spelling, i64-width pins with >2^32 payloads whose low 32 bits collide, `MyStr` content-vs-pointer compare, plus the #1060-walk width shapes (`Bool` follows the type-param field, so an i32-sized `MyInt`/`MyStr`/`Future` manifests); a genuine free `T` (dead base-generic clone, #912) still compiles via its scalar fallback. #1077: `show`/`hash` of `Tuple` (raw-args Tuple plan branch) and of bare aliased-Unit values (literal-name top-level arms) loud-skipped — all four now compute (exact renders, payload-sensitive hash), literal controls pinned. #1078: element-wise `==` on arrays of parameterized ADTs (`Array>`, literal included) pointer-compared (the `IndexExpr` operand's element head drops its type args) — equal/distinct/`>2^32`/aliased-element shapes, non-generic-array + direct-compare controls; mutation-validated per site (canonicalization helper, width fn, both dispatch gates, derivability gate, Tuple plan branch, top-level Unit arms, IndexExpr recovery arm — each flips exactly its own subset) | @@ -191,7 +191,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_string_length_soundness.py` | 15 | 278 | #802 — string_length code-point vs UTF-8 byte soundness: a non-literal `string_length` defers to Tier 3 (the issue's `"é"` probe no longer proves `== 1` at Tier 1), a string-literal length is modeled at its exact UTF-8 byte count (`== 2` for `"é"`), and the boolean predicates `string_contains` / `string_starts_with` / `string_ends_with` stay Tier 1 (sound under UTF-8 self-synchronization), while a predicate over an astral (> U+2FFFF) or lone-surrogate literal defers to Tier 3 (z3.StringVal cannot model those code points) | | `test_errors.py` | 62 | 657 | Error code registry, diagnostic formatting, serialisation, SourceLocation, and error display sync — the canonical `E001` diagnostic must match each of its mirrors: `README.md`, `docs/index.html`, `spec/00-introduction.md`, `AGENTS.md`'s example `--json` block, and the hardcoded example in `scripts/build_site.py` that generates `docs/index.md` (#829; `AGENTS.md`'s ellipsis-truncated description/rationale are prefix-compared, its `error_code`/`spec_ref`/`fix` exactly) | | `test_eq_contract_874.py` | 13 | 430 | `eq`/`compare` ability ops in contract position: codegen canonicalization + verifier Tier-1 discharge/counterexample, where-fn contracts, compare Ordering-sort materialization, shadowing guard (#874) | -| `test_formatter.py` | 534 | 3,638 | Comment extraction, interior comment positioning, expression/declaration formatting, match arm block bodies, §1.8 rule 2 in value position (a `let`-bound `match`/`if` expands exactly as one in statement position, and a comment above an arm inside a statement's value stays on that arm), blank-line preservation (§1.8 rule 13 — gaps between statements, before a block result and around a comment, collapsed to one and never invented), idempotency, parenthesization, spec rules, ability declarations | +| `test_formatter.py` | 536 | 3,638 | Comment extraction, interior comment positioning, expression/declaration formatting, match arm block bodies, §1.8 rule 2 in value position (a `let`-bound `match`/`if` expands exactly as one in statement position, and a comment above an arm inside a statement's value stays on that arm), blank-line preservation (§1.8 rule 13 — gaps between statements, before a block result and around a comment, collapsed to one and never invented), idempotency, parenthesization, spec rules, ability declarations | | `test_cli.py` | 273 | 4,612 | CLI commands (check, verify, compile, run, serve, test, fmt, version, quiet), subprocess integration, JSON error paths (including the `verify --json` `obligations` array and its summary-reproducibility pin, #967, and the #1242 partition pin — the array is emitted unfiltered, a refuted obligation is counted by no summary field, and it still joins its E500 on the location key), runtime traps, arg validation, multi-file resolution, IO exit codes, --explain-slots (including the #1208 naming pins — an alias in type-argument position is tabled resolved, and a `forall` variable shadowing a module alias keeps the two parameter stacks apart — and the #1217 `where`-helper tables: the helper prints indented under its parent, appears in the JSON qualified as `parent.helper`, and inherits the enclosing `forall` variables so the shadowing holds inside it too), `builtins`/`effects`/`errors` introspection dispatch, and a USAGE-completeness guard (every dispatched `cmd_` handler has a help row) | | `test_introspect.py` | 39 | 221 | `vera builtins/effects/errors --json` registry introspection (#539): the `{schema, items}` envelope, count-equals-registry differential per registry, error-phase derivation, effect/ability `kind` tagging, the parameterised `Exn` effect, and best-effort `since` attribution with full-coverage guards | | `test_resolver.py` | 20 | 602 | Module resolution, path lookup, parse caching, circular import detection, the E011/E012/E013 diagnostic contract, internal-error isolation (a compiler bug is not masked as E013), and the transitive-closure return of `resolve_imports` (#890 — a diamond yields each reachable module once, direct imports tagged `direct`, the transitive one not) | @@ -209,7 +209,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_markdown.py` | 94 | 610 | Markdown parser: block/inline parsing, rendering, round-trips, edge cases | | `test_lsp.py` | 146 | 2470 | LSP transport + coordinate layer (#222 Phase C) and language features (#222 Phase D): parametrized code-point↔UTF-16 goldens incl. astral-plane fixtures and surrogate-pair snapping, Span (1-based, exclusive-end) and SourceLocation (0-based col) → LSP Range conversions, point→token-range widening, DocumentStore open/change/close + index invalidation, an in-process handler-drive test, and one stdio end-to-end round-trip against the real `vera lsp` subprocess (initialize → didOpen → shutdown → exit) pinning serverInfo + textDocumentSync capabilities; plus the Phase D feature suite — parse-error single-diagnostic path, type-error verification short-circuit, tier=3 in E520 diagnostic data, per-function tier Hint synthesis (and its suppression for functions with violated obligations), smallest-enclosing-span hover, De Bruijn slot goto (most-recent-parameter jump, out-of-range None, off-slot None, and the #1208 keying pins: a parameterised reference resolves, an alias-spelled parameter is reachable from a canonically-spelled reference, and a `forall` variable shadowing a module alias lands on the right parameter), and typed-hole completion (inside/after hole, away-from-hole None); plus the Phase E speculativeEdit suite — identical-text all-unchanged, breaking edit surfaces newly_undischarged (violated nat_sub) with canonical state untouched, strengthening edit surfaces newly_discharged, parse/type errors report ok:false, deleted functions report removed, proof_delta purity; plus the Phase F1 proposeEdit suite — the apply gate (clean and strengthening edits apply, breaking and non-compiling edits refuse), force overriding both gates with the delta still reported, wiring against a structural fake server (apply round-trip with exact full-document replacement range, refuse touches no canonical state, unopened-URI clamp sentinel), and full-document-range goldens (trailing-newline virtual line, UTF-16 end column); plus the Phase F2 strengthenContract suite — splice goldens (first-clause-only replacement with byte-identical remainder, ensures variant, unknown-fn None), the call-site audit pin (tightened precondition refused with newly_undischarged call_pre items, canonical state untouched), provable-ensures strengthening applies, and the three splice-target refusal paths (no analysis, unparseable document, unknown function); plus the Phase F3 addEffect suite — transitive-caller closure goldens (diamond in declaration order, leaf, unknown-fn None, recursion appears once), handler bounding (#725: a caller discharging the effect around its only call site drops out of the closure and is not rewritten, while a second unhandled path, a call in a handler clause, a handler for another effect, a handler naming a different instance of the same effect (`handle[State]` against a `State` propagation, which the checker does not discharge — end-to-end that caller must still be rewritten and the candidate must still apply, with the matching `State` propagation against the same fixture as the positive control that this handler key does prune something), and a bare `where`-helper call all keep it in, as does a refinement type argument at either depth (`Exn<{ @Int \| p }>` and `Exn>` render as their bare base type but discharge nothing of `Exn` — also pinned end-to-end), while an unparameterised `handle[IO]` does bound an `IO` propagation, a whitespace-spelled `State< Int >` request still bounds, and nesting bounds in either order (a matching handler inside a foreign one, a foreign one inside a matching one) — plus a key-level pin that `handle[Mod.IO]` keeps its module, and the effect-less query pinned handler-unaware; plus the two boundary pins the review added — a call in the handler's STATE INITIALISER keeps its edge, since the initialiser is evaluated in the enclosing scope before the handler is installed (pinned beside the E125 the checker raises there against a `pure` caller, with the identical call in the handler body clean as the contrast), and an alias-spelled handler does not bound a `State` propagation though the checker discharges it (`handle[State]` with `type MyAlias = Int` — the spelling comparison's under-prune, #1292, with the alias-spelled request as the control that does prune)), effect-row rewrite goldens (pure to singleton set, source-preserving append, already-present None, base-name identity blocking State next to State), diamond propagation applying one multi-site candidate with the bystander untouched, mixed append/replace rows with already-satisfied callers skipped, the fully-satisfied no-op shape, and the two refusal paths; plus the #728 instruction-contract suite — the LSP message carries description, rationale, and the Fix: paragraph (also pinning single E501 emission at the LSP surface), and a bare diagnostic maps to the description alone | | `test_browser.py` | 413 | 5,094 | Browser parity: Python/wasmtime vs Node.js/JS-runtime output equivalence across IO, State, contracts, Markdown, Regex, and the examples the browser target can execute (two explicit lists in the file, not the whole `examples/` directory — interactive stdin, file IO, `DB` and the non-standalone `modules` example are excluded with their reasons recorded); plus the #349 `runtime.mjs` coverage battery — per-value-type and per-key-type Map variants, per-element-type Set variants, cold `Decimal` branches (exact-zero sign, negative-shift division, `decRoundPlaces` special cases, non-finite storage), `readJson`/`json_stringify` across every Json ADT tag, the Regex/Json `Result.Err` arms, and nested-Markdown walks. Two operations carry a canonical form the specification states rather than merely agreeing across the hosts, so their batteries assert more than equality: `json_stringify` (spec §9.7.1) pins the expected string on every Json ADT tag and on the number-rendering boundaries, checks three-pass idempotence, checks that a non-finite `JNumber` fails on both hosts *and* prints nothing, pins the object key orders an ordinary JS object cannot carry (array-index keys, which ES enumeration hoists to the front in ascending numeric order, and a `__proto__` key, whose assignment writes a prototype instead of a field) on both a parsed and a program-built object, and checks the reference host's own number rendering differentially against a real `JSON.stringify` over doubles drawn from raw bit patterns; `md_render` (§9.7.3) pins the expected render, re-renders it to prove the fixed point, runs the round-trip property over a corpus carrying the container and multi-line shapes a flat corpus misses, and renders `MdBlock` values the test builds directly, since several renderer rules — a container's child separator, an empty container, a code span wider than one backtick — are unreachable through `md_parse`. plus the #1306/#1308 accept-domain battery (`TestBrowserJsonAcceptDomainParity1306_1308`), which runs one `.wasm` under both runtimes and compares the WHOLE stdout — arm taken and `Err` message together — over the JavaScript constants, over numbers that overflow to an infinity in both their exponent and integer spellings, and over lone surrogates at every position a string can occupy, with the expected sentences imported from `vera/wasm/json_serde.py` so `runtime.mjs`'s hand-copied duplicates are held against the originals, beside acceptance controls (matched pairs, finite boundary values, underflow to `0`, `"NaN"` as a string value), a ten-case host-native-message battery pinning that neither the substitute-and-re-parse probe nor the value-start constraint hijacks an unrelated syntax error (`-NaN` is the case needing both rules — the substitution alone turns it into `-0` and manufactures a refusal the reference host never makes), a precedence case fixing that a non-finite constant outranks a lone surrogate on both hosts though each reaches that answer by a different route, and a document-order case fixing that overflow and lone surrogate are resolved by one walk rather than by two per-host precedence rules that would agree on every single-violation document; `md_parse` itself is not yet at parity and its remaining divergence classes are a tracked bug ([#1301](https://github.com/aallan/vera/issues/1301)); the suite pins the shapes the two implementations agree on | -| `test_conformance.py` | 1195 | 144 | Parametrized conformance suite: parse, check, verify, run, format idempotency across 239 programs; a negative entry fails at the stage `expected_error_stage` names (`check`, the default, or `compile` — which also asserts the program type-checks cleanly first) | +| `test_conformance.py` | 1205 | 144 | Parametrized conformance suite: parse, check, verify, run, format idempotency across 241 programs; a negative entry fails at the stage `expected_error_stage` names (`check`, the default, or `compile` — which also asserts the program type-checks cleanly first) | | `test_prelude.py` | 29 | 585 | Prelude injection: Option/Result/array operation detection, combinator shadowing, type aliases, the reserved namespace every injected alias declaration lives in — checked against the checker's own E154 regex rather than a second spelling of the rule, since an alias the prelude injects outside it is one codegen resolves and the checker leaves opaque (#1184/#1221) — end-to-end compilation | | `test_checker_apply_fn.py` | 18 | 455 | #854 — `apply_fn` as a checker special form: zero-warning pins (API + CLI `--json` + closures.vera), E201 arity / E202 type / non-function-first-arg errors, E122/E125 effect-row enforcement for applied fn values, E151 redefinition rejection, variadic two-param application, prelude combinator regression pins | | `test_prelude_diagnostics.py` | 8 | 271 | #851 — prelude combinator skip-warnings: unreferenced-prelude E602/E604 suppression (zero-warning minimal compile, API + CLI `--json`), `` origin attribution for referenced-but-skipped combinators (text + `to_dict`), transitive reference scan, and user-fn warning locations pinned unchanged | @@ -231,11 +231,11 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_doc_builtin_shadowing.py` | 8 | 107 | `check_doc_builtin_shadowing.py` gate ([#819](https://github.com/aallan/vera/issues/819)): reject-set membership (opaque built-ins in, overridable combinators out), top-level + `where`-block `fn ` definitions flagged, non-built-in / overridable / prose-mention ignored, and the shipped docs are currently clean | | `test_runtime_traps.py` | 98 | 3,255 | Runtime trap categorisation (#516 Stage 1), out-of-bounds host-read bounds check (#1145), stdout/stderr-on-trap preservation (#522), `IO.print` live tee (#543), and trap source backtrace (#516 Stage 2): `_classify_trap` per-`kind` mapping (`divide_by_zero`/`out_of_bounds`/`stack_exhausted`/`unreachable`/`overflow`/`contract_violation`/`unknown`), plus `host_error` from `_classify_host_error` on `execute()`'s non-`Trap` branch, `WasmTrapError` shape + `RuntimeError` substitutability, end-to-end `cmd_run` text + JSON envelopes including `trap_kind`, captured `stdout`, captured `stderr`, JSON-mode "no stderr leak" invariant, cross-stream code-order regression using merged `redirect_stdout`/`redirect_stderr`, the v0.0.123 tee suite (live streaming, write-count + order preservation, JSON-mode tee suppression, trap preservation invariant under tee, per-write flush count, default-execute silence), and the v0.0.124 source-mapping suite — `_resolve_trap_frames` unit tests covering user-fn / built-in / built-in-prefix / monomorphized base-name fallback / unknown-name / no-frames-attribute / leaf-first ordering preservation; end-to-end `cmd_run` text-mode + JSON-mode backtrace including the **leaf-first** ordering invariant; contract-violation backtrace in both text and JSON modes; direct `execute()` `WasmTrapError.frames` attachment; **suppression marker** for collapsed leading runtime-helper frames (mocked `vera.codegen.execute` with synthetic `is_builtin=True` leaf frames so the collapse logic is testable deterministically); source-map population for top-level fns + lifted closures (with span-value assertion against the closure literal's exact line range); and the no-builtin-leakage regression that pins built-in helpers (`alloc` / `gc_collect` / `contract_fail`) NOT being registered in `fn_source_map`; plus the v0.0.125 Stage 3 suite (`#547`) — text-mode `Fix:` block surfacing with position-ordering invariant (Fix appears after the source backtrace), text-mode block suppression for `contract_violation` (no empty header noise), JSON-mode `fix` field always-present (schema stability) including the empty-string case, `_TRAP_FIX_PARAGRAPHS` table-completeness assertion (every kind in the taxonomy has a Fix paragraph entry), and the column-wrap invariant (~76 chars max per line, two-space indent under the `Fix:` heading); plus the UTF-8 hardening suite **`TestHostPrintInvalidUtf8589`** (`#589` / `#592`) — after `#592` centralised the `errors="replace"` invariant into the single `vera.runtime.text.safe_utf8_decode` helper — reached only through a shared `_slice_and_decode` helper (`vera/runtime/heap.py`) that the three WASM-memory string readers (`_read_wasm_string` and `_read_string_export` there, and `vera/wasm/markdown.py::_read_string`) delegate to, with the `host_print` / `host_stderr` / `host_contract_fail` host imports and the String-return extractor in `execute()` routing through those readers rather than decoding inline: one helper unit test pinning the invariant once (invalid bytes → U+FFFD, valid + empty pass through), three wire-real end-to-end tests that drive the **production** readers (`_read_wasm_string` / markdown `_read_string` behind a synthetic-WAT `probe` host import; `_read_string_export` against a real exported memory, also covering its out-of-bounds → `None` pointer-fallback) over a region seeded with invalid UTF-8 — so a strict-decode regression surfaces as a `UnicodeDecodeError` escaping wasmtime's trampoline, and the host imports / extractor are transitively covered — and one synthetic-WAT end-to-end test that imports `vera.print` and calls it with raw invalid UTF-8 bytes to pin the wasmtime-trampoline fact independently (a Python `UnicodeDecodeError` inside a host import escapes as a "python exception" cause iff the host decode is strict); the six pre-`#592` structural source-grep assertions were retired by the centralisation; plus the Ctrl-C-during-host-import suite **`TestHostSleepKeyboardInterrupt`** ([#595](https://github.com/aallan/vera/issues/595) / [#599](https://github.com/aallan/vera/issues/599)) — after the v0.0.160 relocation to a single `except KeyboardInterrupt` handler in `execute()` (enabled by `wasmtime>=45.0.0`'s `except BaseException` trampoline fix): one structural assertion that the four per-host-import `raise _VeraExit(130)` guards are gone and the centralized handler maps to `exit_code=130`, plus four end-to-end tests that compile real Vera programs calling `IO.sleep(...)`, `IO.read_char(())`, a mocked fused `await` ([#841](https://github.com/aallan/vera/issues/841) — `Future.result()` patched to interrupt), and a live in-flight fused `await` (no mocking — `_thread.interrupt_main()` fired only once the server confirms the request arrived, handler then released so the executor teardown has a real worker to wait out; post-[#848](https://github.com/aallan/vera/issues/848) the progress print precedes the `async(...)`, so program order makes its stdout assertion deterministic), raise `KeyboardInterrupt` from inside the blocking call, and assert the program exits with `ExecuteResult.exit_code == 130` (pre-interrupt stdout preserved) instead of a raw Python traceback escaping wasmtime's trampoline; plus the host-callback surface suite **`TestHostCallbackErrorSurface1302`** / **`TestClassifyHostError1302`** ([#1302](https://github.com/aallan/vera/issues/1302)) — `execute()` classified on the exception's TYPE NAME (`Trap` / `WasmtimeError`), so a host import raising an ordinary Python exception skipped the conversion and escaped as a 63-line interpreter traceback with the captured streams dropped, and in `--json` mode with no envelope emitted at all. The conversion is now keyed on the BOUNDARY (everything escaping the guest invocation), and the suite drives a real `json_stringify(JNumber(nan()))` program through all three surfaces: `execute()` raising a `WasmTrapError` of `kind="host_error"` carrying the host's sentence, the pre-failure stdout and the original exception as `__cause__`; text-mode `cmd_run` asserted on the ABSENCE of `Traceback` / `File "` / `wasmtime` and a sub-ten-line diagnostic, since asserting only that the sentence appears would still pass on the pre-fix output where it was the traceback's last line; and JSON-mode `cmd_run` producing a parseable envelope with `trap_kind`, an always-present empty `fix`, `frames`, and the captured `stdout`. **`TestHostErrorDebugKnob1302`** covers the escape hatch the conversion needs — `VERA_DEBUG_HOST_ERRORS` (ENVIRONMENT.md) re-raises the original exception so a binding bug stays diagnosable — as a deliberate pair, one test proving the knob does something and one proving its absence is what produces the one-liner, since neither alone distinguishes a working knob from unconditional behaviour, plus the truthiness table shared with `VERA_EAGER_GC` and an end-to-end `cmd_run` case | | `test_serve.py` | 8 | 189 | #305 `vera serve` driver end-to-end: GET/POST echo round-trips (method/path/headers/body cross the host↔guest boundary via `build_request_adt` / `decode_response_adt`), handler status propagation, runtime contract violation → 500 with `trap_kind` JSON, `State` isolation across requests (instance-per-request pinned), and clean `make_server` validation errors (missing / wrong-signature `handle`), and an eager-GC round-trip pinning the Request builder's shadow-rooting; all on ephemeral ports | -| `test_wasi_target.py` | 271 | 2,142 | #237 WASI Preview 2 target (spec chapter 13): component emission validated live against the real wasmtime host — parse (`Component(engine, wat)`), instantiate (`Linker.add_wasip2()` + `WasiConfig`), and execute (stdout/stderr capture, env, argv incl. a 500-arg GC-pressure stress and a >64 KiB arena-cap trap, preopen file round-trips + errno mapping, stdin incl. UTF-8 multibyte, clocks, random bounds, exit, contract-violation text on WASI stderr, overflow); the family gate (clean diagnostic naming unsupported families, never a silent fallback); the core-emission pin (default `--target wasm` WAT untouched); `cmd_compile`/`cmd_run --target wasi-p2` CLI integration (binary component artifact, `--wat` component text, JSON envelopes, trap-kind classification through the component boundary, exit-code 0/1 degradation, `--fn` rejection); the `execute_wasi_p2` host runner (env passthrough, argv, stderr capture, String-main `wasi:cli/run` fallback); the **dual-target conformance differential** (all 169 run-level conformance programs driven under both targets, byte-identical stdout/stderr required — 119 are dual-tested and 50 skip *loudly* rather than passing silently: 43 whose compiled WAT imports a host family outside `IO`/`Random` (`state`, `map`, `json`, `set`, `decimal`, `html`, `md`, `regex`, `db`), 6 with no public zero-argument `main`, and 1 calling a nondeterministic op. The excluded set is defined by those three properties rather than by a filename list, so it stays accurate as programs are added); a stock-`wasmtime`-CLI smoke test (skips when the CLI is not installed); and the Stage-D **server world** (`world="server"`): incoming-handler emission pins (adapter lift, 32-slot dispatch table, @0.2.0 version pin, no `wasi:cli/run`), #305 handler validation + server family-gate diagnostics (rejected IO ops, non-String map instantiations, unsupported families), the cli-world pin (default emission carries no server machinery), Request/Response layout tripwires, and a stock-`wasmtime serve` smoke battery (host-vs-served differential over a method/path/header/body matrix incl. duplicate-header later-wins, in-guest map-op order parity, `IO.print` console routing, trap→500 with symbolized backtrace + violation text, graceful 500s for forbidden headers and out-of-range status, a 1 MiB GC-stress echo, and an eager-GC shadow-push mutation validation; skips when the CLI is not installed) | +| `test_wasi_target.py` | 273 | 2,142 | #237 WASI Preview 2 target (spec chapter 13): component emission validated live against the real wasmtime host — parse (`Component(engine, wat)`), instantiate (`Linker.add_wasip2()` + `WasiConfig`), and execute (stdout/stderr capture, env, argv incl. a 500-arg GC-pressure stress and a >64 KiB arena-cap trap, preopen file round-trips + errno mapping, stdin incl. UTF-8 multibyte, clocks, random bounds, exit, contract-violation text on WASI stderr, overflow); the family gate (clean diagnostic naming unsupported families, never a silent fallback); the core-emission pin (default `--target wasm` WAT untouched); `cmd_compile`/`cmd_run --target wasi-p2` CLI integration (binary component artifact, `--wat` component text, JSON envelopes, trap-kind classification through the component boundary, exit-code 0/1 degradation, `--fn` rejection); the `execute_wasi_p2` host runner (env passthrough, argv, stderr capture, String-main `wasi:cli/run` fallback); the **dual-target conformance differential** (all 172 run-level conformance programs driven under both targets, byte-identical stdout/stderr required — 120 are dual-tested and 52 skip *loudly* rather than passing silently: 45 whose compiled WAT imports a host family outside `IO`/`Random` (`state`, `map`, `json`, `set`, `decimal`, `html`, `md`, `regex`, `db`), 6 with no public zero-argument `main`, and 1 calling a nondeterministic op. The excluded set is defined by those three properties rather than by a filename list, so it stays accurate as programs are added); a stock-`wasmtime`-CLI smoke test (skips when the CLI is not installed); and the Stage-D **server world** (`world="server"`): incoming-handler emission pins (adapter lift, 32-slot dispatch table, @0.2.0 version pin, no `wasi:cli/run`), #305 handler validation + server family-gate diagnostics (rejected IO ops, non-String map instantiations, unsupported families), the cli-world pin (default emission carries no server machinery), Request/Response layout tripwires, and a stock-`wasmtime serve` smoke battery (host-vs-served differential over a method/path/header/body matrix incl. duplicate-header later-wins, in-guest map-op order parity, `IO.print` console routing, trap→500 with symbolized backtrace + violation text, graceful 500s for forbidden headers and out-of-range status, a 1 MiB GC-stress echo, and an eager-GC shadow-push mutation validation; skips when the CLI is not installed) | ## Conformance Suite -The conformance suite is a collection of 239 small, focused programs in `tests/conformance/` that systematically validate every language feature against the spec. Most programs are self-contained; the module-focused Chapter 8 cases use `import` statements where needed, and `ch07_cross_module_contracts.vera` still depends on `ch07_cross_module_contracts_lib.vera`. Each program tests one feature or a small group of related features. +The conformance suite is a collection of 241 small, focused programs in `tests/conformance/` that systematically validate every language feature against the spec. Most programs are self-contained; the module-focused Chapter 8 cases use `import` statements where needed, and `ch07_cross_module_contracts.vera` still depends on `ch07_cross_module_contracts_lib.vera`. Each program tests one feature or a small group of related features. Simon Willison [argues](https://simonwillison.net/tags/conformance-suites/) that conformance suites are a "huge unlock" for language projects — they transform development from trust-based to verification-based. The conformance suite serves as the definitive specification artifact that any implementation (or agent) can validate against. @@ -262,7 +262,7 @@ Each conformance program declares the deepest pipeline stage it must pass: | `parse` | Source text is syntactically valid | 0 | | `check` | Parses and type-checks cleanly | 49 | | `verify` | Type-checks and all contracts verified by Z3 | 20 | -| `run` | Compiles to WASM and executes correctly | 170 | +| `run` | Compiles to WASM and executes correctly | 172 | Almost all programs are at the `run` level — they compile and execute, producing correct results. Forty-nine programs (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch03_typed_holes`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_cross_module_contracts_lib`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch07_bare_effect_op_rejected`, `ch08_ambiguous_import_adt_lib_bool`, `ch08_ambiguous_import_adt_lib_int`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_lib_bool`, `ch08_ambiguous_import_lib_int`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_cross_module_generic_lib`, `ch08_module_generic_diamond_base`, `ch08_module_prelude_adt_contention_rejected`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_transitive_module_import_base`, `ch08_visibility_private`, `ch08_xmod_widen_lib`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_eq_non_derivable_rejected`, `ch09_http`, `ch09_inference`, `ch09_ord_adt_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) are at the `check` level. Thirty-six of them — `ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch07_bare_effect_op_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, and `ch02_alias_cycle_rejected` — are **negative tests** that assert a specific diagnostic (E206, E135, E183, E201, E127, E153, E153, E153, E130, E130, E174, E182, E217, E156, E156, E155, E155, E011, E154, E154, E154, E154, E154, E154, E150, E152, E151, E242, E243, E207, E208, E208, E209, E128, E336, and E132 respectively) via the manifest's `expected_error` field. One more — `ch08_module_prelude_adt_contention_rejected` — is a negative at the `compile` stage rather than at `check`: it carries `expected_error_stage: "compile"` beside `expected_error: E621`, so the harness asserts it type-checks CLEANLY and is then refused by `vera compile` with that code, which is the property a codegen-phase diagnostic exists for. `ch09_http` and `ch09_inference` are environment-gated (network / API key). Twenty programs (`ch03_slot_let_chains`, `ch03_slot_noncommutative`, `ch04_nested_option_ctor`, `ch04_primitive_obligations`, `ch05_apply_fn_typing`, `ch06_adt_sort_disambiguation`, `ch07_cross_module_contracts`, `ch07_invisible_import_op_name_lib`, `ch07_io_read_char`, `ch07_io_sleep`, `ch07_random_effect`, `ch08_state_alias_module_table_lib`, `ch08_module_generic_diamond_mid1`, `ch08_module_generic_diamond_mid2`, `ch08_state_alias_per_module_lib`, `ch08_transitive_module_import_mid`, `ch09_http_server`, `ch09_invisible_import_ability_op_lib`, `ch09_math_builtins`, `ch09_nested_helper_family_op_name_lib`) are at the `verify` level, using Z3-provable contracts — a library module is pinned at the deepest level it reaches, so the two per-module alias-table libraries are verified rather than only checked. @@ -408,7 +408,7 @@ tests/conformance/ ├── ch01_int_literals.vera # Chapter 1: Integer literals ├── ch01_float_literals.vera # Chapter 1: Float64 literals ├── ch01_string_escapes.vera # Chapter 1: String escape sequences -├── ... # 239 programs total, organized by spec chapter +├── ... # 241 programs total, organized by spec chapter ├── ch07_state_handler.vera # Chapter 7: State effect handler ├── ch07_exn_handler.vera # Chapter 7: Exn effect handler ├── ch09_numeric_builtins.vera # Chapter 9: Numeric built-in functions @@ -971,9 +971,9 @@ Twenty-nine scripts in `scripts/` validate cross-cutting concerns beyond unit te | Script | What it validates | |--------|-------------------| -| `check_conformance.py` | All 239 conformance entries hold at their declared level (parse/check/verify/run) — positives pass; the negatives fail at the stage their `expected_error_stage` names (`check` by default, or `compile`, which also asserts the program type-checks cleanly) with their `expected_error` E-code | +| `check_conformance.py` | All 241 conformance entries hold at their declared level (parse/check/verify/run) — positives pass; the negatives fail at the stage their `expected_error_stage` names (`check` by default, or `compile`, which also asserts the program type-checks cleanly) with their `expected_error` E-code | | `check_examples.py` | All 42 `.vera` examples pass `vera check` + `vera verify` | -| `check_corpus_canonical.py` | All 288 corpus programs (recursive over `examples/` + `tests/conformance/`) are in canonical form under `vera fmt` | +| `check_corpus_canonical.py` | All 290 corpus programs (recursive over `examples/` + `tests/conformance/`) are in canonical form under `vera fmt` | | `check_examples_readme.py` | Every `vera run` command in examples/README.md references an existing file and exported function | | `check_spec_examples.py` | 189 parseable code blocks from spec chapters: parse, type-check, and verify | | `check_readme_examples.py` | All Vera code blocks in README.md parse correctly | @@ -1080,9 +1080,9 @@ The repository configures 36 hooks across two stages: 34 run at the commit stage | `ruff check .` | Lint Python with ruff (default `F` + `E` rules) | | `mypy vera/` | Type-check compiler in strict mode | | `pytest tests/ -q` | Run full test suite | -| `check_conformance.py` | All 239 conformance entries hold at their declared level — positives pass; negatives fail at the stage their `expected_error_stage` names (`check` or `compile`) with their `expected_error` E-code | +| `check_conformance.py` | All 241 conformance entries hold at their declared level — positives pass; negatives fail at the stage their `expected_error_stage` names (`check` or `compile`) with their `expected_error` E-code | | `check_examples.py` | All 42 examples pass `vera check` + `vera verify` | -| `check_corpus_canonical.py` | All 288 `examples/` + `tests/conformance/` programs (recursive) are in canonical form (`vera fmt`) | +| `check_corpus_canonical.py` | All 290 `examples/` + `tests/conformance/` programs (recursive) are in canonical form (`vera fmt`) | | `check_examples_readme.py` | `vera run` commands in `examples/README.md` reference existing files and exported functions | | `check_readme_examples.py` | README code blocks parse correctly | | `check_examples_doc.py` | EXAMPLES.md code blocks parse correctly | diff --git a/docs/SKILL.md b/docs/SKILL.md index e2cf8d7f..08f3458f 100644 --- a/docs/SKILL.md +++ b/docs/SKILL.md @@ -2402,7 +2402,7 @@ public fn main(@Unit -> @Unit) ## Conformance Suite -The `tests/conformance/` directory contains 239 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. +The `tests/conformance/` directory contains 241 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. Each program is organized by spec chapter (`ch01_int_literals.vera`, `ch04_match_basic.vera`, `ch07_state_handler.vera`, etc.) and the `manifest.json` file maps features to programs. When you need to see how a specific construct works, check the conformance program before reading the spec. diff --git a/docs/index.html b/docs/index.html index 34cca628..4ed15a2b 100644 --- a/docs/index.html +++ b/docs/index.html @@ -699,7 +699,7 @@

This page is also a machine-readable specificati Vera is under active development

- A complete compiler with 164 built-in functions, ten algebraic effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge), contract-driven testing via Z3, a language server with agent-facing proof deltas, and a 14-chapter specification. A 239-program conformance suite and 42 worked examples are validated against the spec on every pull request. All of it is developed openly on GitHub and released under the MIT licence. + A complete compiler with 164 built-in functions, ten algebraic effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge), contract-driven testing via Z3, a language server with agent-facing proof deltas, and a 14-chapter specification. A 241-program conformance suite and 42 worked examples are validated against the spec on every pull request. All of it is developed openly on GitHub and released under the MIT licence.

diff --git a/docs/index.md b/docs/index.md index e261e478..b1763be5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -281,7 +281,7 @@ For other models: point them at [`SKILL.md`](https://veralang.dev/SKILL.md) via ## Status -Vera is under [active development](https://raw.githubusercontent.com/aallan/vera/main/ROADMAP.md). A complete compiler with 164 built-in functions, ten algebraic effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge), contract-driven testing via [Z3](https://www.microsoft.com/en-us/research/project/z3-3/), and a 14-chapter specification. A 239-program conformance suite and 42 worked examples are validated against the spec on every pull request. All of it is developed openly on [GitHub](https://github.com/aallan/vera) and released under the MIT licence. +Vera is under [active development](https://raw.githubusercontent.com/aallan/vera/main/ROADMAP.md). A complete compiler with 164 built-in functions, ten algebraic effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge), contract-driven testing via [Z3](https://www.microsoft.com/en-us/research/project/z3-3/), and a 14-chapter specification. A 241-program conformance suite and 42 worked examples are validated against the spec on every pull request. All of it is developed openly on [GitHub](https://github.com/aallan/vera) and released under the MIT licence. ## Links diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 51104aa1..8dcc8ce8 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -2408,7 +2408,7 @@ public fn main(@Unit -> @Unit) ## Conformance Suite -The `tests/conformance/` directory contains 239 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. +The `tests/conformance/` directory contains 241 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. Each program is organized by spec chapter (`ch01_int_literals.vera`, `ch04_match_basic.vera`, `ch07_state_handler.vera`, etc.) and the `manifest.json` file maps features to programs. When you need to see how a specific construct works, check the conformance program before reading the spec. @@ -2487,7 +2487,7 @@ Read `SKILL.md` for the full language reference. It covers syntax, slot referenc ### Conformance programs as reference -The conformance suite in `tests/conformance/` contains 239 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-seven negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) instead must *fail* with the E-code in their `expected_error` field, at the stage their `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. +The conformance suite in `tests/conformance/` contains 241 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-seven negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) instead must *fail* with the E-code in their `expected_error` field, at the stage their `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. ### Workflow @@ -2666,9 +2666,9 @@ Each stage is a module with a single public API function (`parse_file`, `transfo pytest tests/ -v # Run all tests (see TESTING.md) pytest tests/test_conformance.py -v # Conformance suite only mypy vera/ # Type-check the compiler -python scripts/check_conformance.py # All 239 conformance programs hold (positives pass; negatives fail with their E-code) +python scripts/check_conformance.py # All 241 conformance programs hold (positives pass; negatives fail with their E-code) python scripts/check_examples.py # All 42 examples must pass -python scripts/check_corpus_canonical.py # All 288 corpus programs in canonical form +python scripts/check_corpus_canonical.py # All 290 corpus programs in canonical form ``` Test helpers follow a pattern: `_check_ok(source)` / `_check_err(source, match)` / `_verify_ok(source)` / `_verify_err(source, match)`. See existing tests for examples. @@ -2677,7 +2677,7 @@ When implementing a new language feature, write the conformance program *first* ### Invariants -- All 239 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first +- All 241 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first - All 42 examples in `examples/` must pass `vera check` and `vera verify` - `mypy vera/` must be clean - `pytest tests/ -v` must pass @@ -3222,7 +3222,7 @@ None of this is Vera-specific, but it validates the design choices. The thesis i This is a real concern. LLMs are trained on trillions of tokens of Python, TypeScript, and JavaScript. A MojoBench study (NAACL 2025) found that even fine-tuned models achieved only 30–35% improvement over base models on Mojo code generation, illustrating the cold-start problem for new languages. -Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (239 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification. +Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (241 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification. ## How does Vera compare to Dafny / Lean / Koka / F*? @@ -3265,7 +3265,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 11,434 tests, including a 239-program conformance suite +- 11,600 tests, including a 241-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/docs/llms.txt b/docs/llms.txt index eb155d32..0bdc4197 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -56,4 +56,4 @@ Current version: 0.1.11. The reference compiler is written in Python. Install th - [TESTING.md](https://raw.githubusercontent.com/aallan/vera/main/TESTING.md): Test suite architecture, coverage data, and test conventions. - [KNOWN_ISSUES.md](https://raw.githubusercontent.com/aallan/vera/main/KNOWN_ISSUES.md): Known bugs and limitations. - [CONTRIBUTING.md](https://raw.githubusercontent.com/aallan/vera/main/CONTRIBUTING.md): Contribution guidelines. -- [Conformance Suite](https://github.com/aallan/vera/tree/main/tests/conformance): 239 programs validating every language feature against the spec. +- [Conformance Suite](https://github.com/aallan/vera/tree/main/tests/conformance): 241 programs validating every language feature against the spec. diff --git a/tests/test_codegen_alias_adt_name_width_1309.py b/tests/test_codegen_alias_adt_name_width_1309.py index 5a2b9770..8b301219 100644 --- a/tests/test_codegen_alias_adt_name_width_1309.py +++ b/tests/test_codegen_alias_adt_name_width_1309.py @@ -260,6 +260,96 @@ def test_primitive_still_beats_a_same_named_alias(self, name: str) -> None: ast.NamedType(name=name, type_args=None)) == expected +class TestReturnTypeIsStringBranchOrder: + """The THIRD consumer of the same pre-alias-branch disease (CR #1323). + + ``_return_type_is_string`` decides whether ``execute()`` decodes a + function's (ptr, len) return as UTF-8 for display, and it tested the + ``Future`` strip before the alias table — where ``Future`` is an ADT + name, not one of ``vera.types.PRIMITIVES``, so the checker resolves an + alias of that name first. Under ``type Future = Array;`` a + ``@Future`` return therefore took the transparent-wrapper strip, + recursed onto ``String``, and was classified a string return — while the + width derivation (fixed for #1309) resolves the alias and lowers it as + an ``Array``. ``vera run`` decoded the array's backing bytes as + text and printed two NULs where the fresh-name control printed the + pointer. Expressible, and measured identically at the branch point, so + it is pre-existing rather than introduced by the #1309 reorder. + + ``String`` stays ahead of the alias branch because it IS a primitive — + the same single exception the width derivation keeps. + """ + + _ALIASED = """\ +type Future = Array; + +public fn main(@Unit -> @Future) + requires(true) + ensures(true) + effects(pure) +{ + ["ab", "cd"] +} +""" + + _CONTROL = _ALIASED.replace("Future", "Zz") + + def test_a_future_named_alias_to_array_is_not_a_string_return(self) -> None: + assert "main" not in _compile_ok(self._ALIASED).fn_string_returns + + def test_the_fresh_name_control_agrees(self) -> None: + """The differential: the same program under a name that is not an + ADT's was always classified correctly, so the alias TARGET is not + what decides this — the name is.""" + assert "main" not in _compile_ok(self._CONTROL).fn_string_returns + + def test_a_future_named_alias_to_string_is_still_a_string_return(self) -> None: + """Over-correction control: resolving the alias must not lose a + genuine string return that reaches one THROUGH the shadowed name.""" + source = """\ +type Future = Array; + +public fn main(@Unit -> @String) + requires(true) + ensures(true) + effects(pure) +{ + "hi" +} +""" + assert "main" in _compile_ok(source).fn_string_returns + + def test_the_genuine_transparent_future_still_decodes(self) -> None: + """The #841 / #1047 behaviour the Future branch exists for, with no + alias shadowing the name — it must survive the reorder.""" + source = """\ +public fn mk(@Unit -> @Future) + requires(true) + ensures(true) + effects(pure) +{ + async("hi") +} +""" + assert "mk" in _compile_ok(source).fn_string_returns + + def test_an_alias_to_a_transparent_future_still_decodes(self) -> None: + """PR #1041's shape: the alias branch must keep substituting its own + parameters, which is what it was moved above, not past.""" + source = """\ +type Deferred = Future; + +public fn mk(@Unit -> @Deferred) + requires(true) + ensures(true) + effects(pure) +{ + async("hi") +} +""" + assert "mk" in _compile_ok(source).fn_string_returns + + class TestAliasOverAdtNameWidthBattery: """The differential: EVERY built-in ADT name x EVERY representation. diff --git a/vera/README.md b/vera/README.md index 4f21c302..84e150d7 100644 --- a/vera/README.md +++ b/vera/README.md @@ -753,7 +753,7 @@ The `ERROR_CODES` dict in `errors.py` maps every code to a short description (16 ## Test Suite -Testing spans a **pytest suite** of 11,434 tests across 171 files — compiler-internals unit tests plus a **conformance suite** (239 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. +Testing spans a **pytest suite** of 11,600 tests across 173 files — compiler-internals unit tests plus a **conformance suite** (241 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. See **[TESTING.md](../TESTING.md)** for the comprehensive testing reference -- test file table, conformance suite details, compiler code coverage, language feature coverage, helper conventions, validation scripts, CI pipeline, and guidelines for adding tests. diff --git a/vera/codegen/core.py b/vera/codegen/core.py index 9cb90465..3f9c6479 100644 --- a/vera/codegen/core.py +++ b/vera/codegen/core.py @@ -2578,26 +2578,30 @@ def _return_type_is_string(self, te: ast.TypeExpr) -> bool: ``str`` for display. Distinguishes ``String`` from ``Array`` — both share the i32_pair WAT shape but only ``String`` has UTF-8 bytes at memory[ptr:ptr+len]. + + The BRANCH ORDER is the checker's, for the same reason + ``_type_expr_to_wasm_type``'s is (#1309, and this is the THIRD + consumer of that disease): ``String`` is a ``vera.types.PRIMITIVES`` + member and so precedes the alias table, while ``Future`` is an ADT + name and so must follow it. Tested the other way round, ``type + Future = Array;`` made a ``@Future`` return take the + transparent-wrapper strip and be classified a string, while the + width derivation resolved the alias and lowered an ``Array`` + — so ``vera run`` decoded the array's backing bytes as UTF-8 and + printed two NULs where the same program under a non-ADT alias name + printed the pointer. """ if isinstance(te, ast.NamedType): if te.name == "String": return True - # Future is representation-transparent (#841 / #1047): a bare - # `Future` return has the same (ptr, len) pair shape as a - # plain String, so `execute()` must decode it for display too. - # Without this strip `mk() -> Future` was absent from - # `fn_string_returns` and `vera run --fn mk` printed the raw - # pointer instead of the string (the emitted WASM is sound — a - # caller that awaits gets the value; only top-level display broke). - if (te.name == "Future" and te.type_args - and len(te.type_args) == 1): - return self._return_type_is_string(te.type_args[0]) # Type aliases — substitute a parameterised alias's own type # params with the concrete `te.type_args` BEFORE recursing # (mirrors `_type_expr_to_wasm_type`'s #635 block below), so # `type Deferred = Future` used as `Deferred` # resolves to String instead of recursing on the bare `T` and - # displaying the raw pointer (PR #1041 review). + # displaying the raw pointer (PR #1041 review). Ahead of the + # `Future` strip below, which names an ADT rather than a + # primitive and which an alias of that name therefore shadows. if te.name in self._type_aliases: alias = self._type_aliases[te.name] alias_params = self._type_alias_params.get(te.name) @@ -2606,6 +2610,16 @@ def _return_type_is_string(self, te: ast.TypeExpr) -> bool: local_subst = dict(zip(alias_params, te.type_args)) alias = substitute_type_vars(alias, local_subst) return self._return_type_is_string(alias) + # Future is representation-transparent (#841 / #1047): a bare + # `Future` return has the same (ptr, len) pair shape as a + # plain String, so `execute()` must decode it for display too. + # Without this strip `mk() -> Future` was absent from + # `fn_string_returns` and `vera run --fn mk` printed the raw + # pointer instead of the string (the emitted WASM is sound — a + # caller that awaits gets the value; only top-level display broke). + if (te.name == "Future" and te.type_args + and len(te.type_args) == 1): + return self._return_type_is_string(te.type_args[0]) if isinstance(te, ast.RefinementType): return self._return_type_is_string(te.base_type) return False From fa419c7c992bc8eb804a83a9691f3b240de33a81 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 16:33:31 +0100 Subject: [PATCH 46/69] Guard the throw payload at the boundary it crosses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `throw(v)` narrows `v` into the `Exn` payload, and since this issue's static half the narrowing carries the same obligation every other binding site does — but codegen emitted no guard, so the obligation's Tier-3 leg promised a runtime check that did not exist. `throw(0 - 5)` under `effects(>)` ran to completion and returned -5 through the `@Nat` payload; the refined spelling did the same, and a `@Byte` payload of 200 reached a `{ @Byte | @Byte.0 < 10 }` slot. The consequence is worse than a wrong answer. A handler clause binds the payload at its declared type, so the verifier hands every downstream consumer the invariant the payload just broke: a `@Nat`-taking function discharging `ensures(@Bool.result)` at Tier 1 from its parameter's type alone reported a postcondition violation at run time, on a postcondition `vera verify` had proved. `throw` now takes the write boundary's guards at its op-call site, beside `put`'s (#1203): the `@Int` -> `@Nat` sign guard, the `@Nat` -> `@Int` widening guard, and — refined first, as at every other narrowing site — the §2.6.5 predicate guard, which traps through `$vera.contract_fail` naming the predicate that failed. The three arms mirror the verifier's own obligation triple one for one, so the payload obligation is `guarded` at all three and its Tier-3 leg is counted rather than disclosed; the refined arm's claim is intersected with the same `_refined_boundary_codegen_guardable` test every other refined site uses, so an erased `@Unit` base or a nested refinement — which codegen emits no guard for — stays honestly unguarded. Reaching the predicate needed the payload's TYPE, which neither of a cell's two names carries: `family` renders the predicate and `base` strips it. `CellNames` therefore carries the type expression its producer already held, rather than parsing a predicate back out of a mangled family name. The predicate lowering is injected into the translation context, because the two halves of a §2.6.5 guard sit on opposite sides of that seam — which local at what width is the context's question, while the trap message, the contract-fail import and the E617/E618 diagnostics are the generator's. A lifted-closure context is left without the emitter deliberately: it carries no effect-op cells, so a `throw` there is not a boundary this could guard (it does not compile at all today), and the absent emitter fails closed at a loud skip rather than emitting a payload the verifier records as guarded. Three findings from the adversarial round are folded in, each a place the `guarded` PROMISE was wider than the guard. The mirror `_refined_boundary_codegen_guardable` answered "guarded" for a refinement OVER a refinement, which `_refinement_guard_parts` refuses outright with a loud E618 — so `vera verify` exited 0 recording a Tier-3 runtime check for a program `vera compile` then refuses, a promise about a run that can never happen; it now bails on a refinement base and the obligation discloses `tier3_unguarded`. The verifier's `QualifiedCall` arm hardcoded `guarded=False` behind a comment stale since #1203, so `Exn.throw(v)` — which codegen lowers by synthesizing a bare node and delegating to the very dispatcher that emits the guards — disclosed E504/E506 for a boundary that traps, and `State.put(v)` had been doing the same since #1203; both now take the bare arm's rule on the same key, so the two spellings of one operation record identical statuses. And two diagnostic rationales (E504, E531) listed the `throw` payload among the sites with no runtime guard, which is false once the guard lands and contradicts the spec sentences this commit amends. An unrefined payload's WAT is byte-identical to before — a differential over all 278 pre-existing corpus programs, compiled and verified on both trees, moves nothing in emitted WAT or in the obligation and diagnostic streams. New run-level conformance program `ch07_exn_payload_guard` carries the legal controls; the violating twins trap, which no conformance level can express, so those are asserted in `tests/test_exn_throw_payload_1268.py` alongside the proved-`ensures` differential. The #1269 width test's adjacency assertion is rewritten to resolve the operand the `throw` consumes, since the guard now sits between the literal and the instruction. Closes #1268 Co-Authored-By: Claude --- AGENTS.md | 8 +- CHANGELOG.md | 1 + CLAUDE.md | 8 +- FAQ.md | 4 +- KNOWN_ISSUES.md | 3 +- README.md | 2 +- ROADMAP.md | 2 +- SKILL.md | 6 +- TESTING.md | 36 +- docs/SKILL.md | 6 +- docs/index.html | 2 +- docs/index.md | 2 +- docs/llms-full.txt | 18 +- docs/llms.txt | 2 +- spec/02-types.md | 4 +- spec/06-contracts.md | 2 +- spec/11-compilation.md | 4 +- tests/conformance/ch07_exn_payload_guard.vera | 59 +++ tests/conformance/manifest.json | 24 +- ..._closure_boundary_widths_1255_1256_1269.py | 22 +- tests/test_exn_throw_payload_1268.py | 457 ++++++++++++++++-- vera/README.md | 4 +- vera/cli.py | 8 +- vera/codegen/closures.py | 8 + vera/codegen/contracts.py | 60 ++- vera/codegen/functions.py | 13 + vera/verifier.py | 138 ++++-- vera/wasm/calls.py | 27 +- vera/wasm/calls_handlers.py | 122 ++++- vera/wasm/context.py | 40 ++ vera/wasm/helpers.py | 11 + 31 files changed, 952 insertions(+), 151 deletions(-) create mode 100644 tests/conformance/ch07_exn_payload_guard.vera diff --git a/AGENTS.md b/AGENTS.md index 7e8890af..237b02c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Read `SKILL.md` for the full language reference. It covers syntax, slot referenc ### Conformance programs as reference -The conformance suite in `tests/conformance/` contains 241 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-seven negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) instead must *fail* with the E-code in their `expected_error` field, at the stage their `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. +The conformance suite in `tests/conformance/` contains 242 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-seven negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) instead must *fail* with the E-code in their `expected_error` field, at the stage their `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. ### Workflow @@ -187,9 +187,9 @@ Each stage is a module with a single public API function (`parse_file`, `transfo pytest tests/ -v # Run all tests (see TESTING.md) pytest tests/test_conformance.py -v # Conformance suite only mypy vera/ # Type-check the compiler -python scripts/check_conformance.py # All 241 conformance programs hold (positives pass; negatives fail with their E-code) +python scripts/check_conformance.py # All 242 conformance programs hold (positives pass; negatives fail with their E-code) python scripts/check_examples.py # All 42 examples must pass -python scripts/check_corpus_canonical.py # All 290 corpus programs in canonical form +python scripts/check_corpus_canonical.py # All 291 corpus programs in canonical form ``` Test helpers follow a pattern: `_check_ok(source)` / `_check_err(source, match)` / `_verify_ok(source)` / `_verify_err(source, match)`. See existing tests for examples. @@ -198,7 +198,7 @@ When implementing a new language feature, write the conformance program *first* ### Invariants -- All 241 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first +- All 242 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first - All 42 examples in `examples/` must pass `vera check` and `vera verify` - `mypy vera/` must be clean - `pytest tests/ -v` must pass diff --git a/CHANGELOG.md b/CHANGELOG.md index c151d375..322a7157 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - **Two imports supplying one bare name are refused, in every namespace** ([#1304](https://github.com/aallan/vera/issues/1304)). Spec §8.5 ordered a local declaration against an import (§8.5.2) and gave the module-qualified form for reaching what a clash hides (§8.5.3), but defined no order between two *imports* that both supply one name. Neither did the implementation, and the gap was reachable: a module importing two dependencies that each export `forall fn gen` — one returning `@Int`, one `@Bool` — bound its bare call to whichever supplier a set of module paths happened to yield first, so one unchanged file was `vera check`-green on one run and `[E121] body has type Bool` on the next. Measured at the branch point across eight consecutive runs and eight hash seeds: accepted on seeds 0, 2 and 3, rejected on 1, 4, 5, 6 and 7, with the winner tracking module-name hash order rather than which import is written first. Codegen's E608 rail caught the *entry-visible* pair before it could matter there; the flap lived in the shapes the rail only reached at compile, from inside a module the entry program merely imports. Spec §8.5.2.2 now states the rule — a program **MUST NOT** leave a namespace with two imports supplying one bare function name — and the checker enforces it as **E155**, a check-phase code for a scope question that had been enforced by a codegen rail at the wrong layer. Refusing is what removes the flap rather than merely labelling it: with no pick to make, there is no iteration order left to expose, which a deterministic first-wins order would not have achieved (it would make the resolved declaration implicit in import sequence, §0.2.2, and let a library *adding* an export silently rebind a downstream bare call). The refusal is **definition-gated**, matching the rail it generalises: it fires because the import pair exists, not because a body names it, so an entry program importing two suppliers and never calling either is refused exactly as E608 already refused it, and rewriting a bare call in module-qualified form does not lift it. Two shapes clear it, both exercised through to their runtime value: a **local declaration** of the name (§8.5.2 — every bare call is then the local one, and each import stays reachable through `dep::name(...)`), or a **selective import** narrowing the other module's list. The ambiguity predicate is the one `namespace_fn_names` already derived for #1281 and #1299, now exposed per namespace as well as unioned, so the layer that refuses early and the layer that backstops it cannot disagree about which shape is ambiguous; the E608 condition keeps its cell, driven through a door that bypasses the checker. An ambiguous name is not injected into the type environment at all — reporting the clash while binding one supplier would leave the follow-on diagnostics keyed to whichever module the injection loop reached first, which is the nondeterminism the refusal exists to remove — so a bare call to it misses with an ordinary `E200` instead — an E-coded diagnostic emitted at **warning** severity, which the `--json` envelope reports in `warnings` rather than `diagnostics` and which does not fail the check on its own (measured: a program whose only diagnostic is `E200` reports `ok: true` and exits 0). The W-series is the separate `W001`/`W002` code namespace, and this is not one of them. **The data namespaces flapped the same way and are folded in.** Spec §8.5.4 gives constructor names the same shadowing rules as function names, which a function-only refusal would have made false: two modules each exporting a `public data Shape` with different constructor field types type-checked on some hash seeds and reported `[E213]` on others (accepted on seeds 2, 8, 9, 10 and 11; rejected on 0, 1, 3, 4, 5, 6 and 7), and the accepting seeds were the worse half — `check` **and** `verify` both passed, and the program died at `run` with an `E609` located at line 0 of the entry file, naming two modules the entry never imported. Data types are now **E156** and constructors **E157**, one code per declaration namespace exactly as codegen splits E608/E609/E610, and reported independently because they come apart: two modules exporting differently-named types that share a constructor name clash on the constructor alone. Their remedy differs from the function one and says so — E609/E610 refuse two modules' same-named data declarations by DECLARATION, consulting neither visibility nor the importer's filter nor local shadowing (the relaxation E608 received in [#1281](https://github.com/aallan/vera/issues/1281) has no data-side twin), so narrowing an import or shadowing the name locally leaves the program `E609` at compile. Both were measured against the fixture and both fail — as does marking one declaration `private` — so the two diagnostics prescribe renaming, and a cell pins that measurement so the fix text cannot drift into offering remedies that do not work. That rail over-breadth is now tracked as [#1317](https://github.com/aallan/vera/issues/1317). **A name the built-in registry already owns is not a clash** — the injection loops are `setdefault` over a `TypeEnv` the built-ins populate first, so a dependency exporting its own `option_map` never wins the bare name (measured as `E201` against the *prelude's* two-argument signature). The first cut of this refusal did not pass the built-in snapshot and reported two such dependencies as a clash, which was a new rejection rather than an earlier one; `namespace_fn_names`' claim that its ambiguity half is identical with or without the prelude argument was wrong for the same reason and is corrected, with the codegen call ordering it depends on now pinned by a cell. +- **A `throw` payload is runtime-guarded, not only obligated** ([#1268](https://github.com/aallan/vera/issues/1268)). `throw(v)` narrows `v` into the `Exn` payload, and since the static half of this issue the narrowing carries the same obligation every other binding site does — but codegen emitted no guard, so the obligation's Tier-3 leg promised a runtime check that did not exist and an unverified `vera compile`/`run` delivered the violating value anyway. `throw(0 - 5)` under `effects(>)` ran to completion and returned **-5** through the `@Nat` payload; the refined spelling (`type Pos = { @Int | @Int.0 > 0 }`) did the same. Worse than a wrong answer: a handler clause binds the payload at its declared type, so the verifier hands every downstream consumer the invariant the payload just broke — a `@Nat`-taking function discharging `ensures(@Bool.result)` at Tier 1 from its parameter's type alone reported a **postcondition violation at run time on a postcondition `vera verify` had proved**. `throw` now takes the write boundary's guards at its op-call site, beside `put`'s ([#1203](https://github.com/aallan/vera/issues/1203)): the `@Int` -> `@Nat` sign guard, the `@Nat` -> `@Int` widening guard, and — refined FIRST, as at every other narrowing site — the §2.6.5 predicate guard for a refined payload, which traps through `$vera.contract_fail` naming the predicate that failed (`Refinement violation in throw(@Pos) / payload: @Int.0 > 0 failed`). The three arms mirror the verifier's own obligation triple one-for-one, so the obligation stream and the emitted guards stay in lock-step: the payload obligation is now `guarded` at all three arms and its Tier-3 leg is counted in `tier3_runtime` rather than disclosed as `tier3_unguarded`, and the refined arm's `guarded` claim is intersected with the same `_refined_boundary_codegen_guardable` test every other refined site uses, so an erased `@Unit` base or a nested refinement — which codegen emits no guard for — stays honestly unguarded. That mirror needed one repair to be true: it answered "guarded" for a refinement OVER a refinement, which `_refinement_guard_parts` refuses outright with a loud `E618` because the outer predicate alone would silently drop the inner membership — so `vera verify` exited 0 recording a Tier-3 runtime check for a program `vera compile` then refuses, a promise about a run that can never happen. It now bails on a refinement base, and the obligation discloses `tier3_unguarded` while `E618` still refuses. The same audit found the **qualified spelling recording something different from the bare one**: the `QualifiedCall` arm hardcoded `guarded=False` behind a comment stale since [#1203](https://github.com/aallan/vera/issues/1203), so `Exn.throw(v)` — which codegen lowers by synthesizing a bare node and delegating to the very dispatcher that emits the guards — disclosed `E504`/`E506` for a boundary that traps, and `State.put(v)` had been doing the same since #1203. Both now take the bare arm's rule on the same key (`op.parent_effect`), so the two spellings of one operation record identical statuses; a user-declared effect's operation and `IO.sleep`'s `@Nat` formal stay the honest [#754](https://github.com/aallan/vera/issues/754) unguarded class. Two diagnostic rationales (`E504`, `E531`) that listed the `throw` payload among the unguarded sites — false once the guard landed, and contradicting the spec sentences this change amends — no longer do. Reaching the predicate needed the payload's TYPE, which neither of a cell's two names carries: `family` renders the predicate and `base` strips it, so `CellNames` now carries the type expression its producer already held rather than parsing one back out of a mangled family name. The predicate lowering itself is injected into the translation context (`set_refinement_guard_emitter`), because the two halves of a §2.6.5 guard sit on opposite sides of that seam — which local at what width is the context's question, while the trap message, the contract-fail import and the E617/E618 diagnostics are the generator's. An unrefined payload's WAT is byte-identical to before: a differential over all 278 pre-existing corpus programs — every `examples/` and `tests/conformance/` program, compiled and verified on both trees — moves nothing, in emitted WAT or in the obligation and diagnostic streams. - **`json_parse` accepts one domain, and both runtimes accept it** ([#1306](https://github.com/aallan/vera/issues/1306)). The reference host parsed with `json.loads`, whose default `parse_constant` admits `NaN`, `Infinity` and `-Infinity`; the browser gated with `JSON.parse`, which refuses them as RFC 8259 requires. So the two hosts disagreed about *which call* rejects a non-finite value: the browser at the parse with a handled `Err`, the reference host at `json_stringify` — and there as a raw Python traceback rather than a Vera error ([#1302](https://github.com/aallan/vera/issues/1302) below). Spec §9.7.1 now states the accepted domain instead of leaving each host to inherit its parser's: RFC 8259-valid text that decodes to finite numbers and strings of Unicode scalar values, everything else `Err` at the parse with the same message on every runtime. **A non-finite number has two entry routes and the domain closes both.** The constants are one; the other is a syntactically valid number that overflows — `1e999`, `-1e999`, `[1e999]`, `{"a":1e309}` — which both host parsers accept, decoding to an infinite `JNumber` that then died at `json_stringify`, the same divergent-refusal-point defect one syntax over. RFC 8259 §6 sets no limit on a number's range and says an implementation may set one; Vera's is the finite `Float64` values, which is exactly what `json_stringify` can write back. **The refusal covers the integer spelling too**, and that half was reference-host-only: `json.loads` returns a Python `int` for a digit string with no fraction and no exponent, so `1` followed by 309 zeros never met a float range check — and then had to become an f64 at the WASM boundary, where `float()` raises. It died with `int too large to convert to float` where `JSON.parse`, which has no int/float split and sees an `Infinity` either way, returned the shared sentence. The integer bound is the double **rounding** boundary (`2**1024 - 2**970`) rather than `sys.float_info.max`, and compared in integer arithmetic: an integer *larger* than the largest finite double still rounds to it and both hosts accept it, so the obvious bound would have traded this divergence for its mirror image, and a bound implemented as `float(value)` would be the very overflow it is looking for. Underflow is not the same question and is not refused: `1e-999` decodes to `0`, finite and in the domain, pinned as a control beside `1e308` and the largest representable double so a refusal cannot generalise from "unrepresentable magnitude" to "large". Both value-level exclusions — overflow and lone surrogate — are found by ONE document-order walk returning the sentence itself, so "whichever comes first names the refusal" is the rule rather than a precedence table the two hosts could implement differently. The domain is the parse-side counterpart of the canonical output form [#1293](https://github.com/aallan/vera/issues/1293) pinned — a non-finite number has no JSON representation in either direction — and with no entry route through `json_parse`, the output-side refusal is now reachable only from a `JNumber` a program *constructed* from `nan()` or `infinity()`. The reference host's `parse_constant` hook **records rather than raises**, and the refusal is decided after the parse completes. Raising on sight would have made it answer a different question from the browser's: Python's scanner calls the hook the moment it sees the token, so `[Infinity_x]` — malformed for a reason that has nothing to do with the constant — would have reported the non-finite sentence natively while the browser reported a syntax error. Recording and continuing asks what the browser asks, by substituting `0` for each bare constant and re-parsing: *would this text be valid JSON if the constants were admitted?* Only then is the constant the whole story, and only then do both hosts say the same sentence; text malformed for any other reason keeps its host-native syntax message, as every syntax error always has. The browser's scan also only considers a token where a *value* may begin — the start of the text, or after `[`, `,` or `:`. Without that it found `NaN` at offset 1 of `-NaN`, substituted, re-parsed `-0` successfully and reported the shared sentence, where the reference host's parser never reaches the token at all and gives a syntax error. `-NaN`, `[-NaN]`, `+Infinity`, `infinity`, `nan`, `NaNx` and `-Infinityx` are all pinned as host-native on both hosts. The parity battery pins all four probe inputs from the issue's table plus the container and multi-constant shapes, compares the whole `Err` message across hosts rather than which arm was taken, and runs beside controls the refusal must not disturb — `"NaN"` as an ordinary string value among them. diff --git a/CLAUDE.md b/CLAUDE.md index 6c6ce211..dde844be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,10 +59,10 @@ VERA_EAGER_GC=1 vera run file.vera # Force GC on every alloc (see ENVIRONMENT.m VERA_DEBUG_HOST_ERRORS=1 vera run file.vera # Re-raise a host callback's own exception (see ENVIRONMENT.md, debug knob for host-binding bugs) mypy vera/ # Type-check the compiler itself -python scripts/check_conformance.py # Verify all 241 conformance programs (positives pass their level; negatives fail with their expected_error E-code) +python scripts/check_conformance.py # Verify all 242 conformance programs (positives pass their level; negatives fail with their expected_error E-code) python scripts/check_examples.py # Verify all 42 examples parse + check + verify python scripts/check_examples_run.py # Run every runnable example trap-free under the native runtime; the rest carry a documented skip property, and an example that is neither is an error -python scripts/check_corpus_canonical.py # Verify all 290 corpus programs are in canonical form (vera fmt) +python scripts/check_corpus_canonical.py # Verify all 291 corpus programs are in canonical form (vera fmt) python scripts/check_examples_readme.py # Verify vera run commands in examples/README.md python scripts/check_spec_examples.py # Verify spec code blocks parse python scripts/check_readme_examples.py # Verify README code blocks parse @@ -94,7 +94,7 @@ See [`TOOLCHAIN.md`](TOOLCHAIN.md) for the CLI cookbook — driving the toolchai - `vera/` — Reference compiler: grammar, parser, AST, transformer, type checker, verifier, codegen, CLI - `examples/` — 42 example Vera programs (all must pass `vera check` and `vera verify`) - `tests/` — Test suite (unit tests + conformance suite) -- `tests/conformance/` — 241 conformance programs validating every language feature against the spec +- `tests/conformance/` — 242 conformance programs validating every language feature against the spec - `scripts/` — CI and validation scripts ## Writing Vera code @@ -131,7 +131,7 @@ Before changing code — **adding or removing** — write the test that proves y ## What not to break - Pre-commit hooks run mypy + pytest + conformance suite + example validation on every commit -- All 241 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first +- All 242 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first - All 42 examples in `examples/` must pass `vera check` and `vera verify` - Version must stay in sync across `pyproject.toml`, `vera/__init__.py`, `docs/index.html`, `README.md`, and `uv.lock` (gated by `scripts/check_version_sync.py`); CHANGELOG.md must also carry a matching `## [X.Y.Z]` section - All tests must pass: `pytest tests/ -v` diff --git a/FAQ.md b/FAQ.md index cb6f8aae..b4b42e73 100644 --- a/FAQ.md +++ b/FAQ.md @@ -236,7 +236,7 @@ None of this is Vera-specific, but it validates the design choices. The thesis i This is a real concern. LLMs are trained on trillions of tokens of Python, TypeScript, and JavaScript. A MojoBench study (NAACL 2025) found that even fine-tuned models achieved only 30–35% improvement over base models on Mojo code generation, illustrating the cold-start problem for new languages. -Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (241 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification. +Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (242 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification. ## How does Vera compare to Dafny / Lean / Koka / F*? @@ -279,7 +279,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 11,600 tests, including a 241-program conformance suite +- 11,623 tests, including a 242-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 45ce79f8..7deeba31 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -23,7 +23,6 @@ Defects in shipped compiler, runtime, or tooling behaviour — this table matche | Spec Chapter 10 is a second, hand-maintained copy of `vera/grammar.lark`, and the rule-name alignment gate `scripts/check_grammar_alignment.py` compares only rule-name headers — three classes of drift pass it unseen. **Terminals, both directions**: the header pattern requires a lowercase lead, so a fabricated terminal added to §10.2 leaves the gate green, and no declared-versus-referenced audit exists in either direction. **Rule references**: restoring a removed ambiguity to a production's right-hand side is invisible, because only headers are compared. **Production bodies**: the class most grammar edits actually fall into. Two instances are live in the chapter on `main` today, both body-level and both this issue's to close: typed holes (`"?" -> hole_expr`, in `grammar.lark` since 2026-03-30) appear nowhere in `primary_expr`, and §10.2's `BLOCK_COMMENT: /\{-[\s\S]*?-\}/` is non-nesting, contradicting both §1.3 ("They nest") and the implementation, which parses `{- a {- b -} c -}` clean. Never a wrong answer from a program — the defect is that the published grammar misdescribes the one the parser has. Fix direction: extend the gate side-aware (terminal audit both ways, reference-set comparison), or fold Chapter 10 toward the DESIGN Grammar row's actual promise of a single-sourced shared grammar rather than a hand-maintained copy held honest by ever-wider cross-checks; the two live instances are fixable independently of which direction wins. | [#1290](https://github.com/aallan/vera/issues/1290) | | `.github/workflows/release.yml`'s `Tag and create GitHub Release` step fails with `HTTP 422: Validation Failed — body is too long (maximum is 125000 characters)` when the CHANGELOG section `scripts/release.py notes` extracts into `RELEASE_NOTES.md` exceeds GitHub's release-body limit. It fired on v0.1.10, whose notes extract to roughly 148,700 bytes — the #1213 burndown's 44-issue section, some 23,000 characters past the limit — and it fired at the worst point in the pipeline: **after** PyPI had accepted the immutable archives and **after** the tag was created, leaving the release half-cut with no repeatable path back. The v0.1.10 GitHub Release was completed by hand, mirroring the step exactly: the run's artifacts downloaded and hash-verified three ways, then `gh release create --verify-tag --latest` with the wheel, sdist and SHA256SUMS, and a generated body — the section's bold bullet lead-ins as a headline index plus a link to the canonical section at the tag. Rare, since it needs a release this large, but rarity is not the mitigating factor here; the landing point is. Fix direction: make the step total — before `gh release create`, regenerate oversized notes into that index form so the release always carries a body that fits, with the full notes staying in `CHANGELOG.md`, which is already the release notes of record. | [#1288](https://github.com/aallan/vera/issues/1288) | | `_infer_vera_type` (monomorphization's Vera-level type namer) still reads only `then_branch` of an `IfExpr` and only `arms[0]` of a `MatchExpr` — the #1276 join fix landed in its WAT-side siblings but not here. Confirmed by inspection; latent: a wrong answer needs an instantiation discovered through a divergent-first-arm conditional, and no check-green program reaching that shape has been constructed (the checker's upstream unification constrains the same joins). The fix is the mechanical #1276 mirror — take the first arm that yields a type — landing with the failing program that proves it, per the test-first rule. | [#1286](https://github.com/aallan/vera/issues/1286) | -| A `throw` payload is statically obligated but not runtime-guarded: `throw(v)` narrows `v` into the `Exn` payload and the narrowing now carries the same obligation every other binding site does — a provably-violating value is a loud E503/E505 and an undischargeable one is a disclosed `tier3_unguarded` E504/E506/E531 — but codegen emits no predicate or sign guard on the payload (`throw` lowers straight to `throw $exn_` with the value on the stack), so an unverified `vera compile`/`run` still passes a violating value through. Same disclosed-unguarded class as the user-effect operation argument in #754 below, and the guard wants the same metadata; fix direction: emit the guard at the `throw` op-call site, reusing the #1203 `put`-store guard emission and the §2.6.5 boundary predicate lowering. | [#1268](https://github.com/aallan/vera/issues/1268) | | `ch05_closure_nat_return` (a run-level conformance program in the pre-commit + CI gate) trapped **once** in a full `check_conformance.py` run (`unreachable` in `main` — the sentinel `assert` or a GC shadow-stack guard) and has not reproduced in ~960 attempts across isolated, parallel, eager-GC, and hash-seed-swept executions; the emitted WAT is deterministic and correct. Suspected rare runtime/GC/wasmtime interaction, tracked so a future intermittent CI red resolves here instead of starting fresh. | [#996](https://github.com/aallan/vera/issues/996) | ## Limitations @@ -36,7 +35,7 @@ Things Vera cannot do yet, as distinct from defects in what it claims to do. | An ADT `decreases` measure is runtime-ranked only when its type's reachable field structure is fully concrete: a parameterized measure (`List`), or a concrete type whose recursion rides through a parameterized field, gets no runtime guard (never a wrong one — the registered generic layout does not describe concrete construction), staying statically checked and Tier-3-disclosed. Per-instantiation `$dec_size_` helpers (the `$eq_` pattern) close it. | [#1177](https://github.com/aallan/vera/issues/1177) | | Tier 2 verification (Z3 guided by `assert`/lemma hints) is specified in §6.3.2 but not implemented, so contracts that need hints fall to Tier 3 runtime checks. Per-monomorphization verification (#732) has since landed; full Tier 2 (hint-guided) stays on the Milestone 4 horizon. | [#427](https://github.com/aallan/vera/issues/427) | | `data invariant(...)` clauses (spec §2.6, §6.2.3) are not implemented — every documented form fails with E130 because the slot environment for the invariant predicate isn't wired up. Refinement types (`{ @T \| predicate }`, §2.6) are the working alternative until this lands. | [#686](https://github.com/aallan/vera/issues/686) | -| **Statically**, every narrowing *binding site* is obligated: a provably-negative value is an E503 error, and an untranslatable narrowing at an unguarded site is an E504 warning — so a program whose narrowings are all statically PROVEN stores no negative `@Nat` at these sites. `vera verify` exits 0 on the E504 warnings too, so verify-clean is the weaker property: a disclosed narrowing is one the static proof did not reach, and at an unguarded site nothing else does either. **At runtime** (an unverified `vera compile`/`run`), the guard covers every concrete *direct* binding site (a nested constructor sub-pattern is the exception, #765 below), generic function-formal calls, and the string/markup builtin `@Nat` parameters (`string_repeat`, `string_pad_start`/`_end`, `string_from_char_code`, `md_has_heading`), but three statically-obligated sites stay unguarded and store a negative silently: the effect-operation argument — user-declared effects, and the built-in `Exn` `throw` payload (#1268), the built-in `State` `put`/`resume` boundaries being obligated and codegen-guarded — (codegen's `_effect_ops` carries only the dispatch target), the generic-instantiated constructor field (constructor layouts carry no per-field `@Nat` mono metadata), and the `nat_to_int`/`nat_to_string` conversion builtins (special-cased before the `_fn_nat_params` guard loop) — no runtime guard is emitted at those three sites; the unguarded residual is disclosed statically as an E504 warning instead. (The static obligation now also covers the function **return** position — an `@Int` value narrowing into a `@Nat` return is an E503 obligation backed by a codegen return guard, #758 — and the value-position tuple/constructor component, which is both statically obligated and runtime-guarded at the function boundary.) #820 threaded the per-component target-type table into codegen (the metadata these narrowing guards need); the generic-field and effect-op sites are now unblocked for wiring, though the narrowing direction is not yet guarded here. | [#754](https://github.com/aallan/vera/issues/754), [#757](https://github.com/aallan/vera/issues/757) | +| **Statically**, every narrowing *binding site* is obligated: a provably-negative value is an E503 error, and an untranslatable narrowing at an unguarded site is an E504 warning — so a program whose narrowings are all statically PROVEN stores no negative `@Nat` at these sites. `vera verify` exits 0 on the E504 warnings too, so verify-clean is the weaker property: a disclosed narrowing is one the static proof did not reach, and at an unguarded site nothing else does either. **At runtime** (an unverified `vera compile`/`run`), the guard covers every concrete *direct* binding site (a nested constructor sub-pattern is the exception, #765 below), generic function-formal calls, and the string/markup builtin `@Nat` parameters (`string_repeat`, `string_pad_start`/`_end`, `string_from_char_code`, `md_has_heading`), but three statically-obligated sites stay unguarded and store a negative silently: the effect-operation argument of a USER-declared effect — the built-in `State` `put`/`resume` boundaries and the built-in `Exn` `throw` payload are obligated and codegen-guarded, the latter with the §2.6.5 predicate guard as well as the sign pair ([#1268](https://github.com/aallan/vera/issues/1268)) — (codegen's `_effect_ops` carries only the dispatch target for the rest), the generic-instantiated constructor field (constructor layouts carry no per-field `@Nat` mono metadata), and the `nat_to_int`/`nat_to_string` conversion builtins (special-cased before the `_fn_nat_params` guard loop) — no runtime guard is emitted at those three sites; the unguarded residual is disclosed statically as an E504 warning instead. (The static obligation now also covers the function **return** position — an `@Int` value narrowing into a `@Nat` return is an E503 obligation backed by a codegen return guard, #758 — and the value-position tuple/constructor component, which is both statically obligated and runtime-guarded at the function boundary.) #820 threaded the per-component target-type table into codegen (the metadata these narrowing guards need); the generic-field and effect-op sites are now unblocked for wiring, though the narrowing direction is not yet guarded here. | [#754](https://github.com/aallan/vera/issues/754), [#757](https://github.com/aallan/vera/issues/757) | | A refinement / `@Nat` narrowing bound in a **nested** constructor sub-pattern (`Some(Some(@PosInt))` on `Option>`) is statically obligated — a `vera verify`-clean program is sound; a bad nested refined narrowing is `E505`, a bad nested `@Nat` narrowing is `E503` (#763) — but the runtime guard covers only the `@Nat` nested bind (verified by run probes: a negative payload traps in both direct and closure positions) — the *refined* nested bind is not guarded, so an unverified compile doesn't trap on a violating nested refined payload; the verifier discloses that residual as `tier3_unguarded`/E506 | [#765](https://github.com/aallan/vera/issues/765) | | A refinement whose base carries a **non-plain type argument** (`{ @Array<{ @Int \| ... }> \| array_length(...) > 0 }`, an `Array` base) gets **no runtime guard at any boundary** — `_refinement_guard_parts` cannot spell the binder slot for such a base, so named-function and closure parameters and returns alike leave the predicate unchecked at run time. The verifier discloses this honestly (`tier3_unguarded`, E506, excluded from runtime-checked totals — `_refined_boundary_codegen_guardable` mirrors the codegen bail, KEEP IN SYNC); the guard itself is the open work. Same disclosure family as #754 / #757 / #765. | [#1036](https://github.com/aallan/vera/issues/1036) | | Contracts that depend on a `handle[...]` expression's value fall to Tier 3 — the verifier does not model the `handle[...]` expression's value or handler-clause state, so an `ensures` over a handle result is runtime-checked rather than statically proved. (Primitive-op and binding obligations *inside* the handle body walk at enclosing-scope precision and can prove Tier-1; a clause body's slot-dependent obligations are fresh-scope Tier-3, while a manifest violation there — `5 / 0` — stays a loud compile error.) | [#439](https://github.com/aallan/vera/issues/439) | diff --git a/README.md b/README.md index 15a5d958..d65c8137 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ cp /path/to/vera/SKILL.md ~/.claude/skills/vera-language/SKILL.md ## Project status -Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 11,600 tests, 95% Python code coverage, 241 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. +Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 11,623 tests, 95% Python code coverage, 242 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. The reference compiler — parser, AST, type checker, contract verifier (Z3), WASM code generator, module system, browser runtime, and runtime contract insertion — is working. The language specification is in draft across [14 chapters](spec/). diff --git a/ROADMAP.md b/ROADMAP.md index c3df6a0f..703d275d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,7 +12,7 @@ Ordering derives from the design principles ([DESIGN.md](DESIGN.md)): verificati ## Where we are -11,600 tests, 241 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. +11,623 tests, 242 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. ## Stage 19 — The verification completeness sprint diff --git a/SKILL.md b/SKILL.md index ef409117..850b216b 100644 --- a/SKILL.md +++ b/SKILL.md @@ -442,7 +442,7 @@ array, useful when processing diagnostics programmatically. - `Unit` — singleton type, value is `()`. Zero-size and **declaration-only**: a `@Unit` parameter (function or handler-clause op) is legal but reading it (`@Unit.0`) is a checker error (E182) — write the literal `()` instead — and a `let` of a zero-size type (`let @Unit = put(5);`) is a checker error (E183) — call the expression as a statement (`put(5);`). Applies to anything with no runtime representation, including `Future`. - `Never` — bottom type (used for non-terminating expressions like `throw`) -**`Int` and `Nat` are interchangeable in both directions.** `@Nat <: @Int` is a formal subtyping rule at the *type* level (use a `@Nat` anywhere `@Int` is expected, no `nat_to_int` call), and `@Int <: @Nat` is permitted by the type checker with a verifier-discharged obligation (`@Int.0 >= 0`). This means `array_length` (declared `@Int`) flows freely into `@Nat` positions without explicit conversion — the verifier proves non-negativity from context or falls back to a runtime check. Both directions carry a *value*-level obligation, because `@Nat` is a u64 and `@Int` an i64: narrowing requires `>= 0` (`E503`/`E504`), and widening requires `<= i64.MAX` (`E530`; or an `E531` warning at the generic-instantiated `@Int`-field component site code generation cannot guard) — a `@Nat` above i64.MAX bit-reinterprets to a negative `@Int`. Runtime guards and verifier obligations correspond at every closure depth (nested closure returns included); the documented residual runs in one direction only. Obligated-but-not-guarded: three narrowing sites are statically obligated yet carry no runtime guard — the effect-operation argument and the generic-instantiated constructor field ([#754](https://github.com/aallan/vera/issues/754)/[#757](https://github.com/aallan/vera/issues/757)), and the `nat_to_int`/`nat_to_string` conversion builtins — where an `E504` warning discloses the unguarded residual rather than claiming a check the runtime never performs. **Do not** insert `nat_to_int` defensively; `@Nat` already flows to `@Int`. Keep a value that may be negative as `@Int`, or use `int_to_nat` (which returns `Option`) when an explicit narrowing must handle the failure case. See spec §2.2.1 for the formal rule. +**`Int` and `Nat` are interchangeable in both directions.** `@Nat <: @Int` is a formal subtyping rule at the *type* level (use a `@Nat` anywhere `@Int` is expected, no `nat_to_int` call), and `@Int <: @Nat` is permitted by the type checker with a verifier-discharged obligation (`@Int.0 >= 0`). This means `array_length` (declared `@Int`) flows freely into `@Nat` positions without explicit conversion — the verifier proves non-negativity from context or falls back to a runtime check. Both directions carry a *value*-level obligation, because `@Nat` is a u64 and `@Int` an i64: narrowing requires `>= 0` (`E503`/`E504`), and widening requires `<= i64.MAX` (`E530`; or an `E531` warning at the generic-instantiated `@Int`-field component site code generation cannot guard) — a `@Nat` above i64.MAX bit-reinterprets to a negative `@Int`. Runtime guards and verifier obligations correspond at every closure depth (nested closure returns included); the documented residual runs in one direction only. Obligated-but-not-guarded: three narrowing sites are statically obligated yet carry no runtime guard — a user-declared effect operation's argument and the generic-instantiated constructor field ([#754](https://github.com/aallan/vera/issues/754)/[#757](https://github.com/aallan/vera/issues/757)), and the `nat_to_int`/`nat_to_string` conversion builtins — where an `E504` warning discloses the unguarded residual rather than claiming a check the runtime never performs. The built-in effects' operation arguments are guarded: the `State` write boundaries, and the `Exn` `throw` payload, which also takes the refinement-predicate guard ([#1268](https://github.com/aallan/vera/issues/1268)). **Do not** insert `nat_to_int` defensively; `@Nat` already flows to `@Int`. Keep a value that may be negative as `@Int`, or use `int_to_nat` (which returns `Option`) when an explicit narrowing must handle the failure case. See spec §2.2.1 for the formal rule. ### Composite types @@ -2461,7 +2461,7 @@ public fn main(@Unit -> @Unit) ## Conformance Suite -The `tests/conformance/` directory contains 241 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. +The `tests/conformance/` directory contains 242 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. Each program is organized by spec chapter (`ch01_int_literals.vera`, `ch04_match_basic.vera`, `ch07_state_handler.vera`, etc.) and the `manifest.json` file maps features to programs. When you need to see how a specific construct works, check the conformance program before reading the spec. @@ -2502,7 +2502,7 @@ Current reference-implementation bugs that an agent writing Vera code is likely |---|---|---|---| | Rare conformance-gate flake | `ch05_closure_nat_return` trapped once in a full conformance run and never again (~960 clean attempts) — suspected runtime/GC timing interaction, not a compiler defect. | If CI reds on this program with `Reached unreachable` in `main`, re-run and report on the issue with wasmtime version + load conditions — do not chase the compiler. | [#996](https://github.com/aallan/vera/issues/996) | -When a Vera program type-checks cleanly, compiles without errors, and then produces a runtime trap you can't explain, runtime trap diagnostics are now Vera-native end-to-end: each trap carries a `kind` label (`divide_by_zero` / `out_of_bounds` / `stack_exhausted` / `unreachable` / `overflow` / `contract_violation` / `host_error` / `unknown`), a per-kind `Fix:` paragraph naming the canonical remediation, and a source backtrace pointing at the offending Vera function and line — not just `wasm trap: `. Tail-recursive iteration runs in constant WASM stack space for both non-allocating ([#517](https://github.com/aallan/vera/issues/517), v0.0.126) and allocating ([#549](https://github.com/aallan/vera/issues/549), v0.0.154) tail calls — the latter prepends a `$gc_sp` restore before each `return_call` to keep the shadow stack bounded across iterations. +When a Vera program type-checks cleanly, compiles without errors, and then produces a runtime trap you can't explain, runtime trap diagnostics are now Vera-native end-to-end: each trap carries a `kind` label (`divide_by_zero` / `out_of_bounds` / `stack_exhausted` / `unreachable` / `overflow` / `contract_violation` / `host_error` / `unknown`), a per-kind `Fix:` paragraph naming the canonical remediation (omitted for `contract_violation` and `host_error`, whose descriptions already carry the specific instruction, and for `unknown`, where there is nothing general to suggest), and a source backtrace pointing at the offending Vera function and line — not just `wasm trap: `. Tail-recursive iteration runs in constant WASM stack space for both non-allocating ([#517](https://github.com/aallan/vera/issues/517), v0.0.126) and allocating ([#549](https://github.com/aallan/vera/issues/549), v0.0.154) tail calls — the latter prepends a `$gc_sp` restore before each `return_call` to keep the shadow stack bounded across iterations. ## Specification Reference diff --git a/TESTING.md b/TESTING.md index d01305b2..b87f8849 100644 --- a/TESTING.md +++ b/TESTING.md @@ -6,9 +6,9 @@ This is the single source of truth for Vera's testing infrastructure, coverage d | Metric | Value | |--------|-------| -| **Tests** | 11,600 across 173 files (~155,000 lines of test code; 11,403 passed + 26 stress, 171 skipped) | +| **Tests** | 11,623 across 173 files (~155,000 lines of test code; 11,426 passed + 26 stress, 171 skipped) | | **Compiler code coverage** | 95% Python, 87% JavaScript (CI minimum: 80%) | -| **Conformance programs** | 241 programs across 9 spec chapters, validating every language feature | +| **Conformance programs** | 242 programs across 9 spec chapters, validating every language feature | | **Example programs** | 42, all validated through `vera check` + `vera verify` | | **Spec code blocks** | 189 parseable blocks from 14 spec chapters: 92 parse, 86 type-check, 85 verify (the rest carry inline `vera:skip` annotations, #538) | | **README code blocks** | 4 Vera blocks (4 validated, 0 annotated) | @@ -43,7 +43,7 @@ pytest tests/test_runtime_traps.py::TestHostErrorDebugKnob1302 -v mypy vera/ # strict mode # Validation scripts -python scripts/check_conformance.py # conformance suite (241 programs, see manifest.json) +python scripts/check_conformance.py # conformance suite (242 programs, see manifest.json) python scripts/check_examples.py # 42 example programs python scripts/check_spec_examples.py # spec code blocks python scripts/check_readme_examples.py # README code blocks @@ -72,13 +72,13 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_naming_env_provenance_1208.py` | 45 | 2,097 | The other half of the #1208 contract: every consumer is handed the ENVIRONMENT the checker rendered under, not just the same renderer. Four provenance seams, each pinned by the adversarial probe that exhibited it — an IMPORTED callee's contract rendered in its DEFINING module's alias namespace (a violated precondition that vanished, and its mirror, a correct call spuriously rejected, both under a `Cnt` that names different bodies on the two sides), an imported GENERIC monomorphized and verified in that same namespace (a lying postcondition that proved clean, plus a verifier↔codegen clone differential over the recounted slot references — the desync is invisible to a unit test on either side), a `forall` variable shadowing a same-named module alias wherever a generic signature renders (the mono clone, a body `let`, the verifier's collapsed premises, and the exported uninstantiated template), and the tester's `SmtContext` holding the narrowed scope its own names were keyed in — the last one behavioural, since a generator handed the wrong scope collapses two parameters onto one variable and returns NO inputs at all. Three of the seams are also crossed against a SECOND component rather than checked for internal consistency, because a wrong-but-consistent scope is invisible from inside one: the verifier's declared parameter names against `slots.slot_table`'s, its `where`-helper scope against `slots.fn_scopes`' accumulation, and the monomorphizer's post-substitution names against what the consumers rebuild on the clone — each independent on the axis under test (which variables the two sides narrow by) but sharing `fn_slot_scope`/`slot_name` below it, so the hand-derived literal rendering beside every comparison is what a shared-renderer defect cannot satisfy. An imported generic nested under a non-generic function is pinned too — both its discovery-time recount and its verification-time clone must run in the DEFINING module's namespace. Three more seams arrived from the PR #1224 review, each with the false Tier-1 or miscompile that exhibited it: an UNPINNED callee (an imported generic's own `where`-helper, which the origin registry never pins) rendering in the module under verification rather than the entry program, whose absence let a violated precondition discharge as true and trap at run time; a callee's refined-RETURN predicate translated in the callee's namespace alongside its `requires`/`ensures`, pinned by provenance because today's bare-headed binder masks it behaviourally; and codegen's declaration-index space keyed PER NAMESPACE, without which a module's stamp turned the main file's forward alias reference into a backward one and a check-clean, verify-clean program read the wrong parameter through valid WASM. Two seams from the #1213 burndown close the same shape from the other side: the prelude's own aliases are injected only into the reserved `Vera` namespace, so no name a program can spell resolves on the codegen side alone (#1221 — the differential compares the checker's and codegen's partition of one signature, with the emitted WAT beside it), and an imported ADT is ordered at the index its OWN module gave it rather than the built-in floor (#1227), each with the control that differs by exactly the namespace under test. Controls carried alongside: the same programs with the alias renamed or the shadowing declaration removed, and the runtime oracle that shows the new E500/E501 agrees with the emitted code rather than merely reporting more | | `test_callee_contract_scope_1220_1225_1226.py` | 40 | 1,433 | A callee's contract is READ in the callee's own module. Three burndown defects, each asserted against the runtime oracle wherever the two directions of a wrong namespace (an obligation that vanishes, one that fires for no reason) look identical from inside the verifier: an E501's `Precondition:` line quoted from the file that DECLARED the clause (#1220 — in the misattribution direction too, both files carrying a plausible `requires` on the same line number, plus the imported-generic `where`-helper whose clause sits past the end of a short importer and used to quote nothing at all); a bare-name call inside an IMPORTED callee's contract resolved through the CALLEE's module registry (#1225 — the false Tier-1 whose run traps and its mirror spurious E501, through the `requires` and the `ensures` path, against a no-collision control); and the refined-RETURN binder derived through the naming layer, so a refinement over a PARAMETERISED base pushes the key its own predicate looks itself up under (#1226 — single-module and cross-module, the second proving the derivation happens INSIDE the callee scope); plus the PR #1239 review round — a module's pinned registry holds what its OWN file imports (a DEPTH-2 chain, the shape the single-level corpus could not exhibit: requires and ensures directions, bare-vs-qualified tier agreement, a name outside the middle module's import filter still missing per §8.5.1, and the mirror gate that filling the registry does not re-export), and every part of a diagnostic follows the declaring module (location, file name and excerpt, including a clause past a shorter importer's end, with a multi-line clause quoted whole), and the characterization of the binder-reference walk's one documented exception — a CLOSURE inside a predicate owns the first `@T.n` in traversal order, whose consequence is a Tier-3 demotion rather than a fact assumed about the wrong term; and the mini-review round — an obligation carries the FILE its line number belongs to, so the documented `(file, line, column)` join between the two `--json` arrays holds for a module-located obligation, with the entry-file control and warm==cold parity, and a multi-line clause is quoted with its `--` comments blanked (a `--` inside a string literal surviving, which a naive split would corrupt) | | `test_alias_application_refinement_base_1237.py` | 11 | 398 | A parameterised alias APPLICATION substitutes its arguments in the verifier's own resolver (#1237). `type Box = T;` applied as `@Box` resolved to `AdtType('T')` — the alias's binder leaking as an ADT name — so a refinement over it failed the modelled-primitive gate, the refined-return fact was dropped, and a valid program was rejected with a spurious E501 while `vera run` returned the right answer. Both halves of the fix are asserted separately: the alias body registers its own parameters as type variables (`substitute` maps type variables, so an ADT-named binder is unsubstitutable however the application side is written) and the application substitutes. Plus depth (an argument that is itself an application, and an alias whose body applies another alias), the bounded direction (a consumer wanting `>= 100` where the refinement grants `>= 18` is still rejected, and the runtime agrees), and the gate that must NOT move — an unmodelled `@Byte` base resolves correctly and still degrades to a Tier-3 runtime check, with a consumer of its predicate still refused | -| `test_exn_throw_payload_1268.py` | 20 | 453 | `throw`'s payload is obligated like every other narrowing site (#1268). `throw(0 - 5)` under `effects(>)` verified at 4/4 Tier 1 with ZERO obligations while `vera run` returned -5 out of the `@Nat` payload — check-green, verify-green, silently wrong — because `throw` is a bare call with no function-registry entry, so the argument loop never saw it and the table-driven fallback added for the same hole at the State `put` was keyed on that one name. All three arms are asserted (the `@Nat` refutation, the refined refutation over a modelled base, and the `@Nat`->`@Int` widening obligation appearing where none existed), plus the #1251(b) concrete gate reaching the payload for free (`throw(200)` into an `Exn<{ @Byte \| @Byte.0 < 10 }>` names the value; the satisfying twin proves at Tier 1), the user-effect contrast that localized the bug (a declared op's argument was loud for the same value — both are loud now, at the same site name), and the refined-alias payload spelling. The unguarded disclosure is checked against codegen rather than asserted: an undischargeable payload must land on the `tier3_unguarded` E504 leg (excluded from the totals), and a run confirms the payload really is unchecked — the two together go red whichever side moves without the other. A dischargeable twin proves the site is not merely always-loud, and the six `Exn` conformance programs are verified whole as canaries so obligating a position that had none names itself instead of arriving as one line of a corpus sweep | +| `test_exn_throw_payload_1268.py` | 32 | 848 | `throw`'s payload is obligated AND runtime-guarded like every other narrowing site (#1268). `throw(0 - 5)` under `effects(>)` verified at 4/4 Tier 1 with ZERO obligations while `vera run` returned -5 out of the `@Nat` payload — check-green, verify-green, silently wrong — because `throw` is a bare call with no function-registry entry, so the argument loop never saw it and the table-driven fallback added for the same hole at the State `put` was keyed on that one name. All three arms are asserted (the `@Nat` refutation, the refined refutation over a modelled base, and the `@Nat`->`@Int` widening obligation appearing where none existed), plus the #1251(b) concrete gate reaching the payload for free (`throw(200)` into an `Exn<{ @Byte \| @Byte.0 < 10 }>` names the value; the satisfying twin proves at Tier 1), the user-effect contrast that localized the bug (a declared op's argument was loud for the same value — both are loud now, at the same site name), and the refined-alias payload spelling. The GUARD is checked against codegen rather than asserted: an undischargeable payload must land on the runtime-guarded `tier3` leg, counted in the totals, and a run must confirm the payload really is stopped — the two together go red whichever side moves without the other (delete the emission and the runs go red; flip the flag back and the statuses do). Both arms are run at the boundary: a `@Nat` payload traps on -5 and delivers 5, a refined `{ @Int \| @Int.0 > 0 }` payload traps through the `$vera.contract_fail` channel on BOTH -5 and 0 — the value that clears the base's `>= 0` and violates the predicate, so a sign guard standing in for the predicate guard fails here — and delivers 7. The type gate carries its own over-refusal control: an `Exn` payload has no invariant to violate, so a negative one is a correct program and must still return -5. Both REPRESENTATIONS are covered — a scalar payload in one local, and a `@String`-based one whose (ptr, len) pair has to be saved in two, checked over the ptr and put back in the right order (the satisfying twin is what shows the order). The **soundness differential** is the point of the guard rather than a property of it: the clause parameter's type is what the verifier hands every downstream consumer, so a consumer discharging `ensures(@Bool.result)` at Tier 1 from its `@Nat` parameter alone is asserted PROVED and then run — pre-fix the run reported a postcondition violation on that proved postcondition, with nothing else in the path (the argument is already `@Nat`-typed, so no call-site narrowing guard fires). A dischargeable twin proves the site is not merely always-loud, and the six `Exn` conformance programs are verified whole as canaries so obligating a position that had none names itself instead of arriving as one line of a corpus sweep. The adversarial round adds the three places the `guarded` PROMISE and the emitted guard could disagree in a direction no value oracle can see, because the program either never runs or runs identically either way: a refinement OVER a refinement is asserted `tier3_unguarded` AND `E618`-refused at compile in one cell (the mirror claimed a runtime check for a program that cannot be compiled at all — either half alone reads as consistent); the bare and qualified spellings of one operation are asserted to record IDENTICAL statuses as a differential rather than two literals, on both arms, with the run confirming which value is the true one (`Exn.throw` was disclosed unguarded while codegen delegated it to the guard-emitting dispatcher, and `State.put` had been since #1203); and `E504`'s rationale is read from a real diagnostic — reached through the site that IS still unguarded, a user-declared effect's operation argument — to pin that it no longer lists the `throw` payload among the sites with no runtime guard | | `test_refinement_binder_convergence_1208.py` | 16 | 459 | Codegen's refinement boundary guard and `vera/naming.py` derive the predicate binder ONCE (#1208). A per-shape differential over a direct refinement, an alias hop, `@Nat`- and `@Byte`-based refinements (both range-conjoining), a composite base whose binder is a RESOLVED argument list, and two non-refinements that must be `None` on both sides — plus the property the convergence exists to hold, that the guard's binder equals the key a predicate's own `@Base.n` resolves to. A per-shape differential is green either way while two copies agree — which is exactly how a duplicated derivation drifts unnoticed — so the load-bearing assertion is a MUTATION: perturb `naming.refinement_binder_parts` and codegen's guard must report the perturbed binder. Codegen's two layered WASM decisions are pinned alongside — the loud E618 for a nested refinement base, reported ONCE per declaration however many call sites consult the derivation and however many clones a generic is instantiated into (two genuinely distinct sites still report twice), and no guard at all for an erased one (parametrized over `@Unit` and `Future`, the corner that erases identically but is not spelled `Unit`) — plus runtime traps proving BOTH the `@Byte` and the `@Nat` range conjunctions reach the emitted check, each pinned by trap `kind` as well as by the conjunct its message names. The mutation perturbs the predicate as well as the binder name: the two travel by different routes, and the range conjunction lives on the predicate. The once-per-site dedup is keyed on a resolved location, so a cross-module pin holds up the premise that a location carries its owning file: two imported library modules of identical shape, declaring their nested refinement at the same line and column, must produce two diagnostics attributed to two files — each quoting its own module's declaration, which is also what catches an attribution pointing past the importer's last line | | `test_checker_effects.py` | 90 | 1,455 | Effect declarations, abilities, effect subtyping, async effect, handler typing (#420 split), and the #1149 built-in-effect redeclaration gate (E152: divergent and faithful `effect IO`, codegen-only `Exn`, a registry-parametrised sweep, and a differential pinning the gate's name set to what `vera effects --json` publishes) | | `test_state_exn_registration.py` | 30 | 1296 | #1210 — State/Exn host-import registration covers the whole handler, not just its body. Four shapes, one per sub-expression position the walk used to miss (a nested handler in a clause body, in the state-init expression, in a clause's `with` update, and an `Exn` handler in a clause body), each check-green and verify-clean and therefore required to COMPILE — pre-fix every one died at whole-module WAT compilation with `unknown func` / `unknown tag`; plus the `i32_pair` cell (`handle[State]` in a `pure` function) that the walk skipped in silence, now the same loud E607 the declared-effect gate emits. The **registration-completeness differential** is the cross-component invariant itself: over every `examples/` + `tests/conformance/` program that compiles, every `state_*` / `exn_*` symbol the emitted WAT REFERENCES must have a matching import or tag DECLARATION — a desync between the registration pass and the lowering pass is invisible to a unit test on either. Round two adds the Exn twin of the silent skip (`handle[Exn]` in a `pure` function — the walk called the shared tag registration and discarded the verdict, so it compiled where the declared-row spelling was a clean E612) and the four CONTRACT positions, which are lowered code: a handler in a `requires`, an `ensures`, an `assert`, or a `decreases` measure. The differential gained a **validation leg** — every HANDLER-BEARING module is handed to `wasmtime.Module` through the exceptions-enabled engine `execute()` uses, because a symbol declared at the WRONG TYPE passes the name comparison while being invalid WASM, and 10 of the 30 handler-bearing modules fail to load with `wasm_exceptions` off — a supported wasmtime configuration, though the current runner defaults it on. The conformance suite's deliberate negatives are filtered out of the sweep: they never reach codegen through `vera check`. Carries floors on programs swept, modules validated, summed symbol references and globally distinct symbols, plus three can-go-red tests: the State and Exn extractions each stripped of their declaration lines, and a planted retyped import that only the validation leg catches. Round five adds the three positions no corpus program contained — a destructuring `let`'s value (which also disarmed the E612 gate), a module call's ARGUMENTS, and a signature refinement predicate reached through the alias table — and a cross-module shape test for the module-call leg. Round seven adds the boundary-guard routes that enumeration missed (a tuple parameter's components, a tuple return's, and a closure's refined formal and return) and the co-extensiveness half those shapes cannot show: a refined tuple behind a CLOSURE formal must declare nothing, since the closure path emits no component guards, and the nested-refinement (E618) and erased-base bails must stay silent registrars too. Plus the cycle guard on the closure signature leg, asserted in both directions — the walk terminates, and with the guard neutered the same walk blows the recursion limit | | `test_closure_lift_boundaries_1234_1235_1245.py` | 18 | 757 | Closure lifting at refinement boundaries — three burndown defects of one seam. **#1245**: `_lift_pending_closures` ran BEFORE `_compile_postconditions`, so a closure created while lowering a refined-RETURN guard, a tuple return's component guards, or an `ensures(...)` predicate was registered and never lifted — the table stayed empty, its `call_indirect` was orphaned, and the #1185 propagation dropped the function and every caller: a check-green, verify-clean program compiling to ZERO exports. The param-position twin (lowered before the lift, so it always worked) is carried as the control that makes it an ORDERING defect, the `ensures`-clause twin shows the same bug with no refinement in sight, and a violating return asserts the lifted guard ENFORCES rather than merely existing. **#1234**: the lift worklist fed itself — a refinement whose predicate holds a closure refined by a type whose chain leads back to it (`type SelfRef = { @Int \| ... fn(@SelfRef -> @Int) ... }`, and equally a mutual `A -> B -> A` or a three-type cycle) had each lift's own boundary guard queue an `AnonFn` for ever, and `vera compile` never returned. All three cycle lengths are asserted on a daemon thread with a wall-clock budget, so a regression fails fast instead of hanging the suite, and each on the [E602] naming the closure it refused (a guard that never fired cannot produce it). Two controls carry the other half — the guard is keyed on the lift CHAIN, not on everything already lifted, so `fn f(@R, @R -> @Int)` and a diamond, which each legitimately lift one predicate's closure twice, must still run; mutation-measured, they are the only two tests a seen-set spelling reddens. **#1235**: a `Tuple` formal crossing into a closure was unguarded where the named path traps — both spellings of the same boundary are run against each other, violating and passing | | `test_byte_literal_joins_1212.py` | 25 | 759 | #1212 — a `@Byte` literal inside a value-position join lowers at the i32 Byte width. `@Byte` is i32 (spec §11) while an int literal defaults to `i64.const`, and the #865 / #1092 coercions each tested for a TOP-LEVEL `IntLit` — which the checker's bidirectional coercion is equally happy to type inside an `if` or `match` branch. Ten write boundaries are parametrized with the literal in a branch (`let`, handler state-init, clause-dispatched `put`, bare `put`, get-clause `resume` — verbatim the form the E602 clause-lowerability skip message recommends — a clause `with` update, a `@Byte` call argument, a generic constructor field at `Box`, a lifted closure's own RETURN whose named twin had been coerced since #865 while the closure path had no such step, and a HETEROGENEOUS join at a `@Byte` return, where the arm the result-type decider reads is already i32 and a sibling is a bare literal — arm ORDER decided which way that one failed, so both orders and both paths are pinned). The module docstring states what that list is and is not: measured coverage, since the checker's single Byte coercion makes the true enumeration "every position propagating a Byte expectation", which nothing enumerates in one place, each a check-green program that failed WASM validation with `type mismatch: expected i32, found i64` before the fix. Every case carries a VALUE oracle (200, distinguishable from every other constant in its fixture) rather than merely asserting the module runs, and a separate test drives the OTHER branch so a fix that marked only the arm the result type is read off would still fail. The controls are the load-bearing half: a plain `@Int` join must stay i64 — pinned on 5,000,000,000, which an i32 store cannot represent, so a spreading mark is a wrong VALUE and not just a validation failure — a Byte join with no literal arm must be untouched, and a Byte-RETURNING literal join must keep its own #865 return coercion. The constructor-field case runs through the real pipeline (checker artifacts threaded), because the #1092 width keys on the checker-recorded target type | -| `test_closure_boundary_widths_1255_1256_1269.py` | 52 | 949 | Widths and pointer-ness at a closure or effect boundary — three burndown defects of one seam, each a boundary answering "what is this declared type" from something other than that type. **#1255**: GC pointer-ness was read off the SYNTACTIC head, so `type SmallByte = { @Byte \| ... }` was rooted on the shadow stack at the closure parameter, return and capture and at the two named-function twins. The oracle is a DIFFERENTIAL against the `@Byte` spelling of the same program rather than an absolute push count — these bodies must allocate or no prologue is emitted at all, so they legitimately root their own intermediates — with the base spelling's own count PINNED beside it, because equality alone also holds when both spellings root the scalar, which is the pre-fix state and what a mutation deleting the exclusion outright would produce. A genuine pointer at each of the four boundaries is the control (rooting nothing anywhere satisfies the differential too), and every shape runs under `VERA_EAGER_GC=1` — a collection at each `$alloc`, where removing a load-bearing push reads back as a wrong value rather than as a passing test. The heap-layout invariant the defect was inert behind is executable here: a module with no string pool at all — the exposure the issue named — still starts its heap above the inline scalar range, and shrinking the two constants that create that margin fires the build guard. **#1256**: the `apply_fn` `call_indirect` signature took each parameter's width from the ARGUMENT, so a `@Byte` formal fed a literal registered two incompatible `$closure_sig` types and trapped; asserted by run AND by the emitted signature list, since a value oracle alone would also pass if both sides converged on the wrong shared width. The join spelling, the refined formal, the function-type-alias arm of the formal recovery, a directly-called named twin and an i64 control (pinned above 2^32, which an i32 parameter cannot carry) surround it. **#1269**: `throw`'s payload was not a `@Byte` write boundary, so `throw(5)` into `Exn<{ @Byte \| @Byte.0 < 10 }>` put an `i64.const` under an i32 tag and failed WASM validation at load. Both halves of the width agreement are pinned — a fix that widened the TAG would also run, and would put a Byte cell at eight bytes everywhere else — across the bare, aliased, refined, branch-literal, qualified-`Exn.throw` and thrown-inside-the-handled-body spellings, the last two reaching registration paths the others do not | +| `test_closure_boundary_widths_1255_1256_1269.py` | 52 | 969 | Widths and pointer-ness at a closure or effect boundary — three burndown defects of one seam, each a boundary answering "what is this declared type" from something other than that type. **#1255**: GC pointer-ness was read off the SYNTACTIC head, so `type SmallByte = { @Byte \| ... }` was rooted on the shadow stack at the closure parameter, return and capture and at the two named-function twins. The oracle is a DIFFERENTIAL against the `@Byte` spelling of the same program rather than an absolute push count — these bodies must allocate or no prologue is emitted at all, so they legitimately root their own intermediates — with the base spelling's own count PINNED beside it, because equality alone also holds when both spellings root the scalar, which is the pre-fix state and what a mutation deleting the exclusion outright would produce. A genuine pointer at each of the four boundaries is the control (rooting nothing anywhere satisfies the differential too), and every shape runs under `VERA_EAGER_GC=1` — a collection at each `$alloc`, where removing a load-bearing push reads back as a wrong value rather than as a passing test. The heap-layout invariant the defect was inert behind is executable here: a module with no string pool at all — the exposure the issue named — still starts its heap above the inline scalar range, and shrinking the two constants that create that margin fires the build guard. **#1256**: the `apply_fn` `call_indirect` signature took each parameter's width from the ARGUMENT, so a `@Byte` formal fed a literal registered two incompatible `$closure_sig` types and trapped; asserted by run AND by the emitted signature list, since a value oracle alone would also pass if both sides converged on the wrong shared width. The join spelling, the refined formal, the function-type-alias arm of the formal recovery, a directly-called named twin and an i64 control (pinned above 2^32, which an i32 parameter cannot carry) surround it. **#1269**: `throw`'s payload was not a `@Byte` write boundary, so `throw(5)` into `Exn<{ @Byte \| @Byte.0 < 10 }>` put an `i64.const` under an i32 tag and failed WASM validation at load. Both halves of the width agreement are pinned — a fix that widened the TAG would also run, and would put a Byte cell at eight bytes everywhere else — across the bare, aliased, refined, branch-literal, qualified-`Exn.throw` and thrown-inside-the-handled-body spellings, the last two reaching registration paths the others do not | | `test_nested_handler_clause_ops.py` | 27 | 971 | #1211 — a handler clause body's bare `get`/`put` belongs to the handler's DECLARATION scope, not to the body it refines. Eight nested shapes, each asserted on all three components (checker accepts, verifier discharges clean, compiled program returns the checker-derived value): `put` in a put clause and in a get clause, a bare `get` in a `with` state-update expression, depth-3 nesting proving the IMMEDIATELY enclosing handler wins, the qualified `State.put` spelling, a nested handle expression inside a clause body (its registries must be restored to the declaration's, not the intervening handler's), and the two op-result-type mirrors — a bare `get(())` in match-scrutinee (`_effect_op_result_wt`) and array-element (`_effect_op_result_vera`) position, both of which emitted invalid WASM for a check-green program before the alignment. Every oracle is derived from the checker's story, never from what codegen emits, and a meta-test asserts each shape still SEPARATES enclosing-cell from inner-cell routing (the pre-fix value is recorded per case) so none can go vacuous. Round two adds the two dispositions of an EMPTY enclosing handler stack — the declared effect row (the only route that reads the restored `_effect_ops`, which every handler-enclosed case bypasses) and the outermost handler in a `pure` function (E122 at check) — the enclosing handler's own clause running on the outward-routed op (a transforming `with` one level out: 300100, where the intrinsic reading gives 300050), `IO.print` inside a clause body, the #1233 same-family refusals (nested handler, `with` expression, declared row) with their different-family control, and the outward-re-entry depth cap (below it, at it with a WAT-size bound, and past it as a loud E602) | | `test_handler_op_ownership_1284.py` | 15 | 419 | #1284 — whose declaration a bare `get`/`put` call site denotes. The checker resolves user-fn-first (pinned directly: an over-applied `get` under a `handle[State]` reports the USER signature's arity), and codegen used to answer that question twice more and differently — the declared-effect row withheld the op when a function owned the name, the handler expression overwrote unconditionally. Four shapes from check-green source, each asserted on the CHECKER's value and on the dispatch target in the emitted WAT: a handled body returning the cell instead of the function's answer (silently 5 for 4), a `@Bool`-returning user `get` whose module WASM validation rejected, same-family nesting refused outright with a spurious `[E602]` naming a State operation the source never contained, and different-family nesting emitting the enclosing cell's getter at the wrong width. A parametrized differential runs all five shapes (the four plus a user `put`) as one table with each case's pre-fix behaviour recorded, so a case that stops distinguishing the two answers is visible rather than vacuous; the controls — an unshadowed handler and an unshadowed declared row, both of which must still reach the intrinsics — are what a fix that simply stopped installing the ops would fail, and `new(State)` under a shadowed op name pins that the #1285 family registry composes with this | | `test_new_state_family_1285.py` | 9 | 323 | #1285 — which cell `new(State)` reads under a multi-`State` effect row. `old()` has been family-keyed since #1205/#1209 while `new()` read the name-keyed op registry, so the two sides of one `ensures` clause read different cells: `effects(, State>)` with `ensures(new(State) == …)` was check-green and verify-green, put `state_get_Int`'s i64 into the Bool comparison's `i32.eq`, and died at load. Three multi-row cases — the width-mismatched shape that could not load, an `Int`/`Nat` pair that loaded and answered about the wrong cell, and `old()` beside `new()` of one family, whose unchanged-cell claim the runtime refuted on a contract the verifier had discharged — plus the single-`State` and alias-spelled controls the whole existing corpus exercises. Each cell is seeded from a caller's handler at a value the other cell is not holding, so a wrong-cell read cannot coincide with the right answer, and a deliberately false postcondition asserts the Tier 3 runtime check really traps, without which every "the program runs" assertion here would prove nothing | @@ -104,7 +104,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_checker_errors.py` | 73 | 1,196 | Error codes, resolution-coverage diagnostics, contracts, error accumulation (#420 split); cyclic type aliases incl. #1059 self-reference through a type argument (`Future`, mutual `Future`/`Future`, `Array`) rejected E132 | | `test_checker_builtins_collections.py` | 97 | 848 | Map / Set / Decimal / Json / Html / Http / Inference built-in type-checking (#420 split) | | `test_checker_builtins_strings.py` | 122 | 945 | String / numeric / type-conversion / float-predicate / string-search / markdown / regex built-in type-checking, removed-legacy-name regression (#420 split) | -| `test_obligations.py` | 766 | 1,741 | Reified proof obligations + warm `VerificationSession` (#222 Phase A): full-corpus differential oracle (warm session == cold `verify()` on diagnostics, summary, and obligation stream, plus warm-twice determinism, across all 42 examples and every verify/run-level conformance program), summary↔obligation tier-bookkeeping consistency (including the #967 `total == tier1_verified + tier3_runtime` leg, plus a focused self-consistency pin on the three call-demotion examples), the #1242 stream partition — over a corpus widened to every conformance program that type-checks, at any level, `len(obligations) == total + violated + tier3_unguarded` and every status is one of the documented five, with the vocabulary read from the `ObligationStatus` Literal so a sixth member fails rather than vanishing from the counts — per-kind unit tests (requires / ensures / decreases / nat_sub / call_pre statuses, counterexamples, error codes), content-key stability + same-text-two-sites span disambiguation, session solver reuse, type-error short-circuit, ADT-registry resync between programs; plus the Phase B incremental suite — identical-source full replay, callee-body-edit replays callers while callee-contract-edit invalidates them, span-shift and ADT-edit conservative invalidation, cross-program isolation, timeout-status never cached (monkeypatched solver), FIFO eviction bound; plus the #727 dedup pin — a violating call in a let RHS records exactly one E501 diagnostic and one call_pre obligation; plus the #1208 call-site rendering pin — a PARAMETERISED callee slot substitutes into the E501 message and its fix instead of falling back to the generic wording | +| `test_obligations.py` | 769 | 1,741 | Reified proof obligations + warm `VerificationSession` (#222 Phase A): full-corpus differential oracle (warm session == cold `verify()` on diagnostics, summary, and obligation stream, plus warm-twice determinism, across all 42 examples and every verify/run-level conformance program), summary↔obligation tier-bookkeeping consistency (including the #967 `total == tier1_verified + tier3_runtime` leg, plus a focused self-consistency pin on the three call-demotion examples), the #1242 stream partition — over a corpus widened to every conformance program that type-checks, at any level, `len(obligations) == total + violated + tier3_unguarded` and every status is one of the documented five, with the vocabulary read from the `ObligationStatus` Literal so a sixth member fails rather than vanishing from the counts — per-kind unit tests (requires / ensures / decreases / nat_sub / call_pre statuses, counterexamples, error codes), content-key stability + same-text-two-sites span disambiguation, session solver reuse, type-error short-circuit, ADT-registry resync between programs; plus the Phase B incremental suite — identical-source full replay, callee-body-edit replays callers while callee-contract-edit invalidates them, span-shift and ADT-edit conservative invalidation, cross-program isolation, timeout-status never cached (monkeypatched solver), FIFO eviction bound; plus the #727 dedup pin — a violating call in a let RHS records exactly one E501 diagnostic and one call_pre obligation; plus the #1208 call-site rendering pin — a PARAMETERISED callee slot substitutes into the E501 message and its fix instead of falling back to the generic wording | | `test_verifier_contracts.py` | 95 | 898 | Z3 verification over the example corpus, trivial/ensures/if-else/let/multi-clause contracts, counterexamples, tier classification, arithmetic, verification summaries, Diverge effect, edge cases, string-length + string-predicate verification (#839 split) | | `test_verifier_nat_obligations.py` | 82 | 1,743 | **`@Nat` subtraction underflow obligation** (#520 — Path-A discharge via requires/path-conditions/path-aware Z3 refutation, pure-literal exclusion, Int-Int and Nat-Int exemptions) and **`@Nat` binding-site narrowing obligation** (#552/#747/#749 — Tier-1 `value >= 0` at let/call-arg/effect-op-arg/ctor-field/match-bind/destructure narrowing — a concrete site classifies `tier3_runtime` (codegen-guarded) while the effect-op argument and generic-instantiated constructor field classify `E504` (obligated but unguarded, #754/#757) whose rationale names its actual cause — an untranslatable value — rather than the untranslatable-or-timeout conflation #1251 removed, walker-recursion pins, `_narrows_into_nat` verifier/codegen soundness parity; PR #972 clone-instantiated side-table substitution — a `Some(@T)` bind in an `Option`-instantiated clone is no narrowing, genuine clone-path narrowings still obligated); #1201 — a builtin `Tuple` parameter's match-bound components carry their declared component facts (a valid ensures over one proves instead of falsely violating) and an `Int` component bound as `@Nat` fires one loud `E503` per component, both mutation-caught (#839 split) | | `test_verifier_primitive_ops.py` | 39 | 662 | **Primitive-operation safety obligations** (#680) — division/modulo by-zero `E526` and array-index-bounds `E527`, the in-bounds/out-of-bounds two-check with float-exemption, honest Tier-3 for opaque lengths, off-by-one and lower-bound pins, De Bruijn-correct fix hints (#839 split) | @@ -122,7 +122,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_soundness_392.py` | 36 | 584 | #392 audit batches 1–2 — verifier soundness/completeness fixes: signed div/mod truncate toward zero (#799), body `assert(P)` carries a Tier-1 obligation (#800), divisions in contract predicates carry a `div_zero` obligation (#801), and the #804 assume-half of #800's `assert` rule — a prior `assert`/`assume` discharges later obligations (including a later call's precondition) + the postcondition at Tier 1, removing false E501/E503/E500/E505 | | `test_int_overflow.py` | 6 | 143 | #798 — `@Int`/`@Nat` arithmetic-overflow obligations (part of the #392 `smt.py` soundness audit): `+`/`-`/`*` on `@Int`/`@Nat` now emit an `int_overflow` obligation (the analog of `nat_sub`/`div_zero`) rather than modelling the operands as Z3's unbounded integers, so a `ensures(@Int.result > @Int.0)` over `@Int.0 + 1` no longer proves a contract the i64/u64 runtime violates under two's-complement wraparound. Unbounded operands leave the obligation undischarged (Tier-3, runtime-guarded); operand bounds that prove the result stays in range discharge it at Tier 1 | | `test_int_overflow_codegen.py` | 62 | 718 | #798 Stage 3 — runtime overflow-trap codegen: the codegen emits a guard at *exactly* the `@Int`/`@Nat` `+`/`-`/`*` sites the verifier obligates, so `vera run`/`vera compile` programs trap on overflow instead of silently wrapping at the i64/u64 boundary. #808 wired the guard to the `vera.overflow_trap` host import, so the trap now classifies `kind="overflow"` (carrying the overflow Fix paragraph) rather than the generic `unreachable`; `TestOverflowTrapKind808` pins that, with controls proving the #520 `nat_sub` underflow and #813 `@Nat`→`@Int` widen guards still classify `unreachable` | -| `test_int_overflow_differential.py` | 256 | 398 | #798 Stage 3 verifier↔codegen classification differential (cross-component soundness rule): the codegen overflow guard must fire at exactly the sites the verifier obligates *and* classify each site's operand type (`@Int` i64 vs `@Nat` u64) identically — else a Tier-1-clean program traps spuriously or a wrapping op slips through unguarded. Over a corpus exercising all five operand combos plus the literal-left ambiguity (a naive codegen mis-classifies it as `@Nat`), asserts the verifier's per-site gated classification equals the codegen's site for site, both sides driven by the same `ast.span_key` | +| `test_int_overflow_differential.py` | 257 | 398 | #798 Stage 3 verifier↔codegen classification differential (cross-component soundness rule): the codegen overflow guard must fire at exactly the sites the verifier obligates *and* classify each site's operand type (`@Int` i64 vs `@Nat` u64) identically — else a Tier-1-clean program traps spuriously or a wrapping op slips through unguarded. Over a corpus exercising all five operand combos plus the literal-left ambiguity (a naive codegen mis-classifies it as `@Nat`), asserts the verifier's per-site gated classification equals the codegen's site for site, both sides driven by the same `ast.span_key` | | `test_nat_int_widening.py` | 36 | 602 | #813 — `@Nat -> @Int` widening coercion obligation (dual of #552 `nat_bind`, part of the #392 soundness audit): a `@Nat` in (i64.MAX, u64.MAX] reinterprets when widened (u64.MAX → -1), so a `nat_to_int_coerce` obligation that the value is `<= i64.MAX` now fires at the return position — provably-in-range → Tier-1, provably-out-of-range (`@Nat.0 >= 2**63`) → loud E530, unbounded → honest Tier-3 (runtime-guarded), with an `@Int -> @Int` control that must not fire; the unguarded generic-`@Int`-field case also has its `E531` rationale read for WHAT IT SAYS — a value bounded on neither side, not the untranslatable-or-timeout conflation #1251 removed. The #813 follow-up adds the explicit `nat_to_int` built-in and heterogeneous `if`/`match` arms with a non-negative-literal alternative; #820 adds the heterogeneous-`@Int`-slot arm, closure argument, and closure return/capture obligations (each per-arm / per-site, with `@Int`-arm and `@Nat`-formal controls that must not fire) | | `test_int_widening_codegen.py` | 52 | 535 | #813 Stage 3 — runtime `@Nat -> @Int` widening-trap codegen: the codegen emits a guard at *exactly* the `@Nat -> @Int` coercion sites the verifier obligates (return, `let`, call argument, and — since #820 — array element, tuple construction/destructure, heterogeneous `if`/`match` arm, closure argument/return), so `vera run`/`vera compile` programs trap when a `@Nat` above i64.MAX would reinterpret to a negative `@Int` instead of silently returning the wrong value. The trap is a bare `unreachable` (shares `_emit_negative_i64_guard` with the #552 nat-bind guard), classified `kind="unreachable"` today (a dedicated widening trap kind is a follow-up) | | `test_int_widening_differential.py` | 26 | 320 | #813 verifier↔codegen behavioural differential (cross-component soundness rule): at every `@Nat -> @Int` coercion site the verifier's `nat_to_int_coerce` classification must AGREE with the runtime — a `tier3` (codegen-guarded) site MUST trap on a `@Nat` above i64.MAX (return / `let` / call-arg / constructor field / ADT sub-pattern / match-bind, and the #820 array-element / tuple-component / heterogeneous-arm / closure argument-return sites), while a `tier3_unguarded` (E531) site must NOT trap (the generic-instantiated `@Int`-field coercion codegen cannot guard). Runs BOTH sides on one corpus so the "runtime-guarded" claim is checked against the actual trap — catching a verifier deferral codegen never guards (unsound silent -1) or a spurious trap | @@ -191,7 +191,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_string_length_soundness.py` | 15 | 278 | #802 — string_length code-point vs UTF-8 byte soundness: a non-literal `string_length` defers to Tier 3 (the issue's `"é"` probe no longer proves `== 1` at Tier 1), a string-literal length is modeled at its exact UTF-8 byte count (`== 2` for `"é"`), and the boolean predicates `string_contains` / `string_starts_with` / `string_ends_with` stay Tier 1 (sound under UTF-8 self-synchronization), while a predicate over an astral (> U+2FFFF) or lone-surrogate literal defers to Tier 3 (z3.StringVal cannot model those code points) | | `test_errors.py` | 62 | 657 | Error code registry, diagnostic formatting, serialisation, SourceLocation, and error display sync — the canonical `E001` diagnostic must match each of its mirrors: `README.md`, `docs/index.html`, `spec/00-introduction.md`, `AGENTS.md`'s example `--json` block, and the hardcoded example in `scripts/build_site.py` that generates `docs/index.md` (#829; `AGENTS.md`'s ellipsis-truncated description/rationale are prefix-compared, its `error_code`/`spec_ref`/`fix` exactly) | | `test_eq_contract_874.py` | 13 | 430 | `eq`/`compare` ability ops in contract position: codegen canonicalization + verifier Tier-1 discharge/counterexample, where-fn contracts, compare Ordering-sort materialization, shadowing guard (#874) | -| `test_formatter.py` | 536 | 3,638 | Comment extraction, interior comment positioning, expression/declaration formatting, match arm block bodies, §1.8 rule 2 in value position (a `let`-bound `match`/`if` expands exactly as one in statement position, and a comment above an arm inside a statement's value stays on that arm), blank-line preservation (§1.8 rule 13 — gaps between statements, before a block result and around a comment, collapsed to one and never invented), idempotency, parenthesization, spec rules, ability declarations | +| `test_formatter.py` | 537 | 3,638 | Comment extraction, interior comment positioning, expression/declaration formatting, match arm block bodies, §1.8 rule 2 in value position (a `let`-bound `match`/`if` expands exactly as one in statement position, and a comment above an arm inside a statement's value stays on that arm), blank-line preservation (§1.8 rule 13 — gaps between statements, before a block result and around a comment, collapsed to one and never invented), idempotency, parenthesization, spec rules, ability declarations | | `test_cli.py` | 273 | 4,612 | CLI commands (check, verify, compile, run, serve, test, fmt, version, quiet), subprocess integration, JSON error paths (including the `verify --json` `obligations` array and its summary-reproducibility pin, #967, and the #1242 partition pin — the array is emitted unfiltered, a refuted obligation is counted by no summary field, and it still joins its E500 on the location key), runtime traps, arg validation, multi-file resolution, IO exit codes, --explain-slots (including the #1208 naming pins — an alias in type-argument position is tabled resolved, and a `forall` variable shadowing a module alias keeps the two parameter stacks apart — and the #1217 `where`-helper tables: the helper prints indented under its parent, appears in the JSON qualified as `parent.helper`, and inherits the enclosing `forall` variables so the shadowing holds inside it too), `builtins`/`effects`/`errors` introspection dispatch, and a USAGE-completeness guard (every dispatched `cmd_` handler has a help row) | | `test_introspect.py` | 39 | 221 | `vera builtins/effects/errors --json` registry introspection (#539): the `{schema, items}` envelope, count-equals-registry differential per registry, error-phase derivation, effect/ability `kind` tagging, the parameterised `Exn` effect, and best-effort `since` attribution with full-coverage guards | | `test_resolver.py` | 20 | 602 | Module resolution, path lookup, parse caching, circular import detection, the E011/E012/E013 diagnostic contract, internal-error isolation (a compiler bug is not masked as E013), and the transitive-closure return of `resolve_imports` (#890 — a diamond yields each reachable module once, direct imports tagged `direct`, the transitive one not) | @@ -209,7 +209,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_markdown.py` | 94 | 610 | Markdown parser: block/inline parsing, rendering, round-trips, edge cases | | `test_lsp.py` | 146 | 2470 | LSP transport + coordinate layer (#222 Phase C) and language features (#222 Phase D): parametrized code-point↔UTF-16 goldens incl. astral-plane fixtures and surrogate-pair snapping, Span (1-based, exclusive-end) and SourceLocation (0-based col) → LSP Range conversions, point→token-range widening, DocumentStore open/change/close + index invalidation, an in-process handler-drive test, and one stdio end-to-end round-trip against the real `vera lsp` subprocess (initialize → didOpen → shutdown → exit) pinning serverInfo + textDocumentSync capabilities; plus the Phase D feature suite — parse-error single-diagnostic path, type-error verification short-circuit, tier=3 in E520 diagnostic data, per-function tier Hint synthesis (and its suppression for functions with violated obligations), smallest-enclosing-span hover, De Bruijn slot goto (most-recent-parameter jump, out-of-range None, off-slot None, and the #1208 keying pins: a parameterised reference resolves, an alias-spelled parameter is reachable from a canonically-spelled reference, and a `forall` variable shadowing a module alias lands on the right parameter), and typed-hole completion (inside/after hole, away-from-hole None); plus the Phase E speculativeEdit suite — identical-text all-unchanged, breaking edit surfaces newly_undischarged (violated nat_sub) with canonical state untouched, strengthening edit surfaces newly_discharged, parse/type errors report ok:false, deleted functions report removed, proof_delta purity; plus the Phase F1 proposeEdit suite — the apply gate (clean and strengthening edits apply, breaking and non-compiling edits refuse), force overriding both gates with the delta still reported, wiring against a structural fake server (apply round-trip with exact full-document replacement range, refuse touches no canonical state, unopened-URI clamp sentinel), and full-document-range goldens (trailing-newline virtual line, UTF-16 end column); plus the Phase F2 strengthenContract suite — splice goldens (first-clause-only replacement with byte-identical remainder, ensures variant, unknown-fn None), the call-site audit pin (tightened precondition refused with newly_undischarged call_pre items, canonical state untouched), provable-ensures strengthening applies, and the three splice-target refusal paths (no analysis, unparseable document, unknown function); plus the Phase F3 addEffect suite — transitive-caller closure goldens (diamond in declaration order, leaf, unknown-fn None, recursion appears once), handler bounding (#725: a caller discharging the effect around its only call site drops out of the closure and is not rewritten, while a second unhandled path, a call in a handler clause, a handler for another effect, a handler naming a different instance of the same effect (`handle[State]` against a `State` propagation, which the checker does not discharge — end-to-end that caller must still be rewritten and the candidate must still apply, with the matching `State` propagation against the same fixture as the positive control that this handler key does prune something), and a bare `where`-helper call all keep it in, as does a refinement type argument at either depth (`Exn<{ @Int \| p }>` and `Exn>` render as their bare base type but discharge nothing of `Exn` — also pinned end-to-end), while an unparameterised `handle[IO]` does bound an `IO` propagation, a whitespace-spelled `State< Int >` request still bounds, and nesting bounds in either order (a matching handler inside a foreign one, a foreign one inside a matching one) — plus a key-level pin that `handle[Mod.IO]` keeps its module, and the effect-less query pinned handler-unaware; plus the two boundary pins the review added — a call in the handler's STATE INITIALISER keeps its edge, since the initialiser is evaluated in the enclosing scope before the handler is installed (pinned beside the E125 the checker raises there against a `pure` caller, with the identical call in the handler body clean as the contrast), and an alias-spelled handler does not bound a `State` propagation though the checker discharges it (`handle[State]` with `type MyAlias = Int` — the spelling comparison's under-prune, #1292, with the alias-spelled request as the control that does prune)), effect-row rewrite goldens (pure to singleton set, source-preserving append, already-present None, base-name identity blocking State next to State), diamond propagation applying one multi-site candidate with the bystander untouched, mixed append/replace rows with already-satisfied callers skipped, the fully-satisfied no-op shape, and the two refusal paths; plus the #728 instruction-contract suite — the LSP message carries description, rationale, and the Fix: paragraph (also pinning single E501 emission at the LSP surface), and a bare diagnostic maps to the description alone | | `test_browser.py` | 413 | 5,094 | Browser parity: Python/wasmtime vs Node.js/JS-runtime output equivalence across IO, State, contracts, Markdown, Regex, and the examples the browser target can execute (two explicit lists in the file, not the whole `examples/` directory — interactive stdin, file IO, `DB` and the non-standalone `modules` example are excluded with their reasons recorded); plus the #349 `runtime.mjs` coverage battery — per-value-type and per-key-type Map variants, per-element-type Set variants, cold `Decimal` branches (exact-zero sign, negative-shift division, `decRoundPlaces` special cases, non-finite storage), `readJson`/`json_stringify` across every Json ADT tag, the Regex/Json `Result.Err` arms, and nested-Markdown walks. Two operations carry a canonical form the specification states rather than merely agreeing across the hosts, so their batteries assert more than equality: `json_stringify` (spec §9.7.1) pins the expected string on every Json ADT tag and on the number-rendering boundaries, checks three-pass idempotence, checks that a non-finite `JNumber` fails on both hosts *and* prints nothing, pins the object key orders an ordinary JS object cannot carry (array-index keys, which ES enumeration hoists to the front in ascending numeric order, and a `__proto__` key, whose assignment writes a prototype instead of a field) on both a parsed and a program-built object, and checks the reference host's own number rendering differentially against a real `JSON.stringify` over doubles drawn from raw bit patterns; `md_render` (§9.7.3) pins the expected render, re-renders it to prove the fixed point, runs the round-trip property over a corpus carrying the container and multi-line shapes a flat corpus misses, and renders `MdBlock` values the test builds directly, since several renderer rules — a container's child separator, an empty container, a code span wider than one backtick — are unreachable through `md_parse`. plus the #1306/#1308 accept-domain battery (`TestBrowserJsonAcceptDomainParity1306_1308`), which runs one `.wasm` under both runtimes and compares the WHOLE stdout — arm taken and `Err` message together — over the JavaScript constants, over numbers that overflow to an infinity in both their exponent and integer spellings, and over lone surrogates at every position a string can occupy, with the expected sentences imported from `vera/wasm/json_serde.py` so `runtime.mjs`'s hand-copied duplicates are held against the originals, beside acceptance controls (matched pairs, finite boundary values, underflow to `0`, `"NaN"` as a string value), a ten-case host-native-message battery pinning that neither the substitute-and-re-parse probe nor the value-start constraint hijacks an unrelated syntax error (`-NaN` is the case needing both rules — the substitution alone turns it into `-0` and manufactures a refusal the reference host never makes), a precedence case fixing that a non-finite constant outranks a lone surrogate on both hosts though each reaches that answer by a different route, and a document-order case fixing that overflow and lone surrogate are resolved by one walk rather than by two per-host precedence rules that would agree on every single-violation document; `md_parse` itself is not yet at parity and its remaining divergence classes are a tracked bug ([#1301](https://github.com/aallan/vera/issues/1301)); the suite pins the shapes the two implementations agree on | -| `test_conformance.py` | 1205 | 144 | Parametrized conformance suite: parse, check, verify, run, format idempotency across 241 programs; a negative entry fails at the stage `expected_error_stage` names (`check`, the default, or `compile` — which also asserts the program type-checks cleanly first) | +| `test_conformance.py` | 1210 | 144 | Parametrized conformance suite: parse, check, verify, run, format idempotency across 242 programs; a negative entry fails at the stage `expected_error_stage` names (`check`, the default, or `compile` — which also asserts the program type-checks cleanly first) | | `test_prelude.py` | 29 | 585 | Prelude injection: Option/Result/array operation detection, combinator shadowing, type aliases, the reserved namespace every injected alias declaration lives in — checked against the checker's own E154 regex rather than a second spelling of the rule, since an alias the prelude injects outside it is one codegen resolves and the checker leaves opaque (#1184/#1221) — end-to-end compilation | | `test_checker_apply_fn.py` | 18 | 455 | #854 — `apply_fn` as a checker special form: zero-warning pins (API + CLI `--json` + closures.vera), E201 arity / E202 type / non-function-first-arg errors, E122/E125 effect-row enforcement for applied fn values, E151 redefinition rejection, variadic two-param application, prelude combinator regression pins | | `test_prelude_diagnostics.py` | 8 | 271 | #851 — prelude combinator skip-warnings: unreferenced-prelude E602/E604 suppression (zero-warning minimal compile, API + CLI `--json`), `` origin attribution for referenced-but-skipped combinators (text + `to_dict`), transitive reference scan, and user-fn warning locations pinned unchanged | @@ -231,11 +231,11 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_doc_builtin_shadowing.py` | 8 | 107 | `check_doc_builtin_shadowing.py` gate ([#819](https://github.com/aallan/vera/issues/819)): reject-set membership (opaque built-ins in, overridable combinators out), top-level + `where`-block `fn ` definitions flagged, non-built-in / overridable / prose-mention ignored, and the shipped docs are currently clean | | `test_runtime_traps.py` | 98 | 3,255 | Runtime trap categorisation (#516 Stage 1), out-of-bounds host-read bounds check (#1145), stdout/stderr-on-trap preservation (#522), `IO.print` live tee (#543), and trap source backtrace (#516 Stage 2): `_classify_trap` per-`kind` mapping (`divide_by_zero`/`out_of_bounds`/`stack_exhausted`/`unreachable`/`overflow`/`contract_violation`/`unknown`), plus `host_error` from `_classify_host_error` on `execute()`'s non-`Trap` branch, `WasmTrapError` shape + `RuntimeError` substitutability, end-to-end `cmd_run` text + JSON envelopes including `trap_kind`, captured `stdout`, captured `stderr`, JSON-mode "no stderr leak" invariant, cross-stream code-order regression using merged `redirect_stdout`/`redirect_stderr`, the v0.0.123 tee suite (live streaming, write-count + order preservation, JSON-mode tee suppression, trap preservation invariant under tee, per-write flush count, default-execute silence), and the v0.0.124 source-mapping suite — `_resolve_trap_frames` unit tests covering user-fn / built-in / built-in-prefix / monomorphized base-name fallback / unknown-name / no-frames-attribute / leaf-first ordering preservation; end-to-end `cmd_run` text-mode + JSON-mode backtrace including the **leaf-first** ordering invariant; contract-violation backtrace in both text and JSON modes; direct `execute()` `WasmTrapError.frames` attachment; **suppression marker** for collapsed leading runtime-helper frames (mocked `vera.codegen.execute` with synthetic `is_builtin=True` leaf frames so the collapse logic is testable deterministically); source-map population for top-level fns + lifted closures (with span-value assertion against the closure literal's exact line range); and the no-builtin-leakage regression that pins built-in helpers (`alloc` / `gc_collect` / `contract_fail`) NOT being registered in `fn_source_map`; plus the v0.0.125 Stage 3 suite (`#547`) — text-mode `Fix:` block surfacing with position-ordering invariant (Fix appears after the source backtrace), text-mode block suppression for `contract_violation` (no empty header noise), JSON-mode `fix` field always-present (schema stability) including the empty-string case, `_TRAP_FIX_PARAGRAPHS` table-completeness assertion (every kind in the taxonomy has a Fix paragraph entry), and the column-wrap invariant (~76 chars max per line, two-space indent under the `Fix:` heading); plus the UTF-8 hardening suite **`TestHostPrintInvalidUtf8589`** (`#589` / `#592`) — after `#592` centralised the `errors="replace"` invariant into the single `vera.runtime.text.safe_utf8_decode` helper — reached only through a shared `_slice_and_decode` helper (`vera/runtime/heap.py`) that the three WASM-memory string readers (`_read_wasm_string` and `_read_string_export` there, and `vera/wasm/markdown.py::_read_string`) delegate to, with the `host_print` / `host_stderr` / `host_contract_fail` host imports and the String-return extractor in `execute()` routing through those readers rather than decoding inline: one helper unit test pinning the invariant once (invalid bytes → U+FFFD, valid + empty pass through), three wire-real end-to-end tests that drive the **production** readers (`_read_wasm_string` / markdown `_read_string` behind a synthetic-WAT `probe` host import; `_read_string_export` against a real exported memory, also covering its out-of-bounds → `None` pointer-fallback) over a region seeded with invalid UTF-8 — so a strict-decode regression surfaces as a `UnicodeDecodeError` escaping wasmtime's trampoline, and the host imports / extractor are transitively covered — and one synthetic-WAT end-to-end test that imports `vera.print` and calls it with raw invalid UTF-8 bytes to pin the wasmtime-trampoline fact independently (a Python `UnicodeDecodeError` inside a host import escapes as a "python exception" cause iff the host decode is strict); the six pre-`#592` structural source-grep assertions were retired by the centralisation; plus the Ctrl-C-during-host-import suite **`TestHostSleepKeyboardInterrupt`** ([#595](https://github.com/aallan/vera/issues/595) / [#599](https://github.com/aallan/vera/issues/599)) — after the v0.0.160 relocation to a single `except KeyboardInterrupt` handler in `execute()` (enabled by `wasmtime>=45.0.0`'s `except BaseException` trampoline fix): one structural assertion that the four per-host-import `raise _VeraExit(130)` guards are gone and the centralized handler maps to `exit_code=130`, plus four end-to-end tests that compile real Vera programs calling `IO.sleep(...)`, `IO.read_char(())`, a mocked fused `await` ([#841](https://github.com/aallan/vera/issues/841) — `Future.result()` patched to interrupt), and a live in-flight fused `await` (no mocking — `_thread.interrupt_main()` fired only once the server confirms the request arrived, handler then released so the executor teardown has a real worker to wait out; post-[#848](https://github.com/aallan/vera/issues/848) the progress print precedes the `async(...)`, so program order makes its stdout assertion deterministic), raise `KeyboardInterrupt` from inside the blocking call, and assert the program exits with `ExecuteResult.exit_code == 130` (pre-interrupt stdout preserved) instead of a raw Python traceback escaping wasmtime's trampoline; plus the host-callback surface suite **`TestHostCallbackErrorSurface1302`** / **`TestClassifyHostError1302`** ([#1302](https://github.com/aallan/vera/issues/1302)) — `execute()` classified on the exception's TYPE NAME (`Trap` / `WasmtimeError`), so a host import raising an ordinary Python exception skipped the conversion and escaped as a 63-line interpreter traceback with the captured streams dropped, and in `--json` mode with no envelope emitted at all. The conversion is now keyed on the BOUNDARY (everything escaping the guest invocation), and the suite drives a real `json_stringify(JNumber(nan()))` program through all three surfaces: `execute()` raising a `WasmTrapError` of `kind="host_error"` carrying the host's sentence, the pre-failure stdout and the original exception as `__cause__`; text-mode `cmd_run` asserted on the ABSENCE of `Traceback` / `File "` / `wasmtime` and a sub-ten-line diagnostic, since asserting only that the sentence appears would still pass on the pre-fix output where it was the traceback's last line; and JSON-mode `cmd_run` producing a parseable envelope with `trap_kind`, an always-present empty `fix`, `frames`, and the captured `stdout`. **`TestHostErrorDebugKnob1302`** covers the escape hatch the conversion needs — `VERA_DEBUG_HOST_ERRORS` (ENVIRONMENT.md) re-raises the original exception so a binding bug stays diagnosable — as a deliberate pair, one test proving the knob does something and one proving its absence is what produces the one-liner, since neither alone distinguishes a working knob from unconditional behaviour, plus the truthiness table shared with `VERA_EAGER_GC` and an end-to-end `cmd_run` case | | `test_serve.py` | 8 | 189 | #305 `vera serve` driver end-to-end: GET/POST echo round-trips (method/path/headers/body cross the host↔guest boundary via `build_request_adt` / `decode_response_adt`), handler status propagation, runtime contract violation → 500 with `trap_kind` JSON, `State` isolation across requests (instance-per-request pinned), and clean `make_server` validation errors (missing / wrong-signature `handle`), and an eager-GC round-trip pinning the Request builder's shadow-rooting; all on ephemeral ports | -| `test_wasi_target.py` | 273 | 2,142 | #237 WASI Preview 2 target (spec chapter 13): component emission validated live against the real wasmtime host — parse (`Component(engine, wat)`), instantiate (`Linker.add_wasip2()` + `WasiConfig`), and execute (stdout/stderr capture, env, argv incl. a 500-arg GC-pressure stress and a >64 KiB arena-cap trap, preopen file round-trips + errno mapping, stdin incl. UTF-8 multibyte, clocks, random bounds, exit, contract-violation text on WASI stderr, overflow); the family gate (clean diagnostic naming unsupported families, never a silent fallback); the core-emission pin (default `--target wasm` WAT untouched); `cmd_compile`/`cmd_run --target wasi-p2` CLI integration (binary component artifact, `--wat` component text, JSON envelopes, trap-kind classification through the component boundary, exit-code 0/1 degradation, `--fn` rejection); the `execute_wasi_p2` host runner (env passthrough, argv, stderr capture, String-main `wasi:cli/run` fallback); the **dual-target conformance differential** (all 172 run-level conformance programs driven under both targets, byte-identical stdout/stderr required — 120 are dual-tested and 52 skip *loudly* rather than passing silently: 45 whose compiled WAT imports a host family outside `IO`/`Random` (`state`, `map`, `json`, `set`, `decimal`, `html`, `md`, `regex`, `db`), 6 with no public zero-argument `main`, and 1 calling a nondeterministic op. The excluded set is defined by those three properties rather than by a filename list, so it stays accurate as programs are added); a stock-`wasmtime`-CLI smoke test (skips when the CLI is not installed); and the Stage-D **server world** (`world="server"`): incoming-handler emission pins (adapter lift, 32-slot dispatch table, @0.2.0 version pin, no `wasi:cli/run`), #305 handler validation + server family-gate diagnostics (rejected IO ops, non-String map instantiations, unsupported families), the cli-world pin (default emission carries no server machinery), Request/Response layout tripwires, and a stock-`wasmtime serve` smoke battery (host-vs-served differential over a method/path/header/body matrix incl. duplicate-header later-wins, in-guest map-op order parity, `IO.print` console routing, trap→500 with symbolized backtrace + violation text, graceful 500s for forbidden headers and out-of-range status, a 1 MiB GC-stress echo, and an eager-GC shadow-push mutation validation; skips when the CLI is not installed) | +| `test_wasi_target.py` | 274 | 2,142 | #237 WASI Preview 2 target (spec chapter 13): component emission validated live against the real wasmtime host — parse (`Component(engine, wat)`), instantiate (`Linker.add_wasip2()` + `WasiConfig`), and execute (stdout/stderr capture, env, argv incl. a 500-arg GC-pressure stress and a >64 KiB arena-cap trap, preopen file round-trips + errno mapping, stdin incl. UTF-8 multibyte, clocks, random bounds, exit, contract-violation text on WASI stderr, overflow); the family gate (clean diagnostic naming unsupported families, never a silent fallback); the core-emission pin (default `--target wasm` WAT untouched); `cmd_compile`/`cmd_run --target wasi-p2` CLI integration (binary component artifact, `--wat` component text, JSON envelopes, trap-kind classification through the component boundary, exit-code 0/1 degradation, `--fn` rejection); the `execute_wasi_p2` host runner (env passthrough, argv, stderr capture, String-main `wasi:cli/run` fallback); the **dual-target conformance differential** (all 173 run-level conformance programs driven under both targets, byte-identical stdout/stderr required — 121 are dual-tested and 52 skip *loudly* rather than passing silently: 45 whose compiled WAT imports a host family outside `IO`/`Random` (`state`, `map`, `json`, `set`, `decimal`, `html`, `md`, `regex`, `db`), 6 with no public zero-argument `main`, and 1 calling a nondeterministic op. The excluded set is defined by those three properties rather than by a filename list, so it stays accurate as programs are added); a stock-`wasmtime`-CLI smoke test (skips when the CLI is not installed); and the Stage-D **server world** (`world="server"`): incoming-handler emission pins (adapter lift, 32-slot dispatch table, @0.2.0 version pin, no `wasi:cli/run`), #305 handler validation + server family-gate diagnostics (rejected IO ops, non-String map instantiations, unsupported families), the cli-world pin (default emission carries no server machinery), Request/Response layout tripwires, and a stock-`wasmtime serve` smoke battery (host-vs-served differential over a method/path/header/body matrix incl. duplicate-header later-wins, in-guest map-op order parity, `IO.print` console routing, trap→500 with symbolized backtrace + violation text, graceful 500s for forbidden headers and out-of-range status, a 1 MiB GC-stress echo, and an eager-GC shadow-push mutation validation; skips when the CLI is not installed) | ## Conformance Suite -The conformance suite is a collection of 241 small, focused programs in `tests/conformance/` that systematically validate every language feature against the spec. Most programs are self-contained; the module-focused Chapter 8 cases use `import` statements where needed, and `ch07_cross_module_contracts.vera` still depends on `ch07_cross_module_contracts_lib.vera`. Each program tests one feature or a small group of related features. +The conformance suite is a collection of 242 small, focused programs in `tests/conformance/` that systematically validate every language feature against the spec. Most programs are self-contained; the module-focused Chapter 8 cases use `import` statements where needed, and `ch07_cross_module_contracts.vera` still depends on `ch07_cross_module_contracts_lib.vera`. Each program tests one feature or a small group of related features. Simon Willison [argues](https://simonwillison.net/tags/conformance-suites/) that conformance suites are a "huge unlock" for language projects — they transform development from trust-based to verification-based. The conformance suite serves as the definitive specification artifact that any implementation (or agent) can validate against. @@ -262,7 +262,7 @@ Each conformance program declares the deepest pipeline stage it must pass: | `parse` | Source text is syntactically valid | 0 | | `check` | Parses and type-checks cleanly | 49 | | `verify` | Type-checks and all contracts verified by Z3 | 20 | -| `run` | Compiles to WASM and executes correctly | 172 | +| `run` | Compiles to WASM and executes correctly | 173 | Almost all programs are at the `run` level — they compile and execute, producing correct results. Forty-nine programs (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch03_typed_holes`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_cross_module_contracts_lib`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch07_bare_effect_op_rejected`, `ch08_ambiguous_import_adt_lib_bool`, `ch08_ambiguous_import_adt_lib_int`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_lib_bool`, `ch08_ambiguous_import_lib_int`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_cross_module_generic_lib`, `ch08_module_generic_diamond_base`, `ch08_module_prelude_adt_contention_rejected`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_transitive_module_import_base`, `ch08_visibility_private`, `ch08_xmod_widen_lib`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_eq_non_derivable_rejected`, `ch09_http`, `ch09_inference`, `ch09_ord_adt_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) are at the `check` level. Thirty-six of them — `ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch07_bare_effect_op_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, and `ch02_alias_cycle_rejected` — are **negative tests** that assert a specific diagnostic (E206, E135, E183, E201, E127, E153, E153, E153, E130, E130, E174, E182, E217, E156, E156, E155, E155, E011, E154, E154, E154, E154, E154, E154, E150, E152, E151, E242, E243, E207, E208, E208, E209, E128, E336, and E132 respectively) via the manifest's `expected_error` field. One more — `ch08_module_prelude_adt_contention_rejected` — is a negative at the `compile` stage rather than at `check`: it carries `expected_error_stage: "compile"` beside `expected_error: E621`, so the harness asserts it type-checks CLEANLY and is then refused by `vera compile` with that code, which is the property a codegen-phase diagnostic exists for. `ch09_http` and `ch09_inference` are environment-gated (network / API key). Twenty programs (`ch03_slot_let_chains`, `ch03_slot_noncommutative`, `ch04_nested_option_ctor`, `ch04_primitive_obligations`, `ch05_apply_fn_typing`, `ch06_adt_sort_disambiguation`, `ch07_cross_module_contracts`, `ch07_invisible_import_op_name_lib`, `ch07_io_read_char`, `ch07_io_sleep`, `ch07_random_effect`, `ch08_state_alias_module_table_lib`, `ch08_module_generic_diamond_mid1`, `ch08_module_generic_diamond_mid2`, `ch08_state_alias_per_module_lib`, `ch08_transitive_module_import_mid`, `ch09_http_server`, `ch09_invisible_import_ability_op_lib`, `ch09_math_builtins`, `ch09_nested_helper_family_op_name_lib`) are at the `verify` level, using Z3-provable contracts — a library module is pinned at the deepest level it reaches, so the two per-module alias-table libraries are verified rather than only checked. @@ -408,7 +408,7 @@ tests/conformance/ ├── ch01_int_literals.vera # Chapter 1: Integer literals ├── ch01_float_literals.vera # Chapter 1: Float64 literals ├── ch01_string_escapes.vera # Chapter 1: String escape sequences -├── ... # 241 programs total, organized by spec chapter +├── ... # 242 programs total, organized by spec chapter ├── ch07_state_handler.vera # Chapter 7: State effect handler ├── ch07_exn_handler.vera # Chapter 7: Exn effect handler ├── ch09_numeric_builtins.vera # Chapter 9: Numeric built-in functions @@ -442,7 +442,7 @@ The manifest is the machine-readable feature inventory — agents can query it t ### Running the conformance suite ```bash -# Via pytest (parametrized — 1,195 tests: five stages × 239 entries) +# Via pytest (parametrized — 1,210 tests: five stages × 242 entries) pytest tests/test_conformance.py -v # Via standalone script (used in CI and pre-commit) @@ -971,9 +971,9 @@ Twenty-nine scripts in `scripts/` validate cross-cutting concerns beyond unit te | Script | What it validates | |--------|-------------------| -| `check_conformance.py` | All 241 conformance entries hold at their declared level (parse/check/verify/run) — positives pass; the negatives fail at the stage their `expected_error_stage` names (`check` by default, or `compile`, which also asserts the program type-checks cleanly) with their `expected_error` E-code | +| `check_conformance.py` | All 242 conformance entries hold at their declared level (parse/check/verify/run) — positives pass; the negatives fail at the stage their `expected_error_stage` names (`check` by default, or `compile`, which also asserts the program type-checks cleanly) with their `expected_error` E-code | | `check_examples.py` | All 42 `.vera` examples pass `vera check` + `vera verify` | -| `check_corpus_canonical.py` | All 290 corpus programs (recursive over `examples/` + `tests/conformance/`) are in canonical form under `vera fmt` | +| `check_corpus_canonical.py` | All 291 corpus programs (recursive over `examples/` + `tests/conformance/`) are in canonical form under `vera fmt` | | `check_examples_readme.py` | Every `vera run` command in examples/README.md references an existing file and exported function | | `check_spec_examples.py` | 189 parseable code blocks from spec chapters: parse, type-check, and verify | | `check_readme_examples.py` | All Vera code blocks in README.md parse correctly | @@ -1080,9 +1080,9 @@ The repository configures 36 hooks across two stages: 34 run at the commit stage | `ruff check .` | Lint Python with ruff (default `F` + `E` rules) | | `mypy vera/` | Type-check compiler in strict mode | | `pytest tests/ -q` | Run full test suite | -| `check_conformance.py` | All 241 conformance entries hold at their declared level — positives pass; negatives fail at the stage their `expected_error_stage` names (`check` or `compile`) with their `expected_error` E-code | +| `check_conformance.py` | All 242 conformance entries hold at their declared level — positives pass; negatives fail at the stage their `expected_error_stage` names (`check` or `compile`) with their `expected_error` E-code | | `check_examples.py` | All 42 examples pass `vera check` + `vera verify` | -| `check_corpus_canonical.py` | All 290 `examples/` + `tests/conformance/` programs (recursive) are in canonical form (`vera fmt`) | +| `check_corpus_canonical.py` | All 291 `examples/` + `tests/conformance/` programs (recursive) are in canonical form (`vera fmt`) | | `check_examples_readme.py` | `vera run` commands in `examples/README.md` reference existing files and exported functions | | `check_readme_examples.py` | README code blocks parse correctly | | `check_examples_doc.py` | EXAMPLES.md code blocks parse correctly | diff --git a/docs/SKILL.md b/docs/SKILL.md index 08f3458f..c11ce0c0 100644 --- a/docs/SKILL.md +++ b/docs/SKILL.md @@ -440,7 +440,7 @@ array, useful when processing diagnostics programmatically. - `Unit` — singleton type, value is `()`. Zero-size and **declaration-only**: a `@Unit` parameter (function or handler-clause op) is legal but reading it (`@Unit.0`) is a checker error (E182) — write the literal `()` instead — and a `let` of a zero-size type (`let @Unit = put(5);`) is a checker error (E183) — call the expression as a statement (`put(5);`). Applies to anything with no runtime representation, including `Future`. - `Never` — bottom type (used for non-terminating expressions like `throw`) -**`Int` and `Nat` are interchangeable in both directions.** `@Nat <: @Int` is a formal subtyping rule at the *type* level (use a `@Nat` anywhere `@Int` is expected, no `nat_to_int` call), and `@Int <: @Nat` is permitted by the type checker with a verifier-discharged obligation (`@Int.0 >= 0`). This means `array_length` (declared `@Int`) flows freely into `@Nat` positions without explicit conversion — the verifier proves non-negativity from context or falls back to a runtime check. Both directions carry a *value*-level obligation, because `@Nat` is a u64 and `@Int` an i64: narrowing requires `>= 0` (`E503`/`E504`), and widening requires `<= i64.MAX` (`E530`; or an `E531` warning at the generic-instantiated `@Int`-field component site code generation cannot guard) — a `@Nat` above i64.MAX bit-reinterprets to a negative `@Int`. Runtime guards and verifier obligations correspond at every closure depth (nested closure returns included); the documented residual runs in one direction only. Obligated-but-not-guarded: three narrowing sites are statically obligated yet carry no runtime guard — the effect-operation argument and the generic-instantiated constructor field ([#754](https://github.com/aallan/vera/issues/754)/[#757](https://github.com/aallan/vera/issues/757)), and the `nat_to_int`/`nat_to_string` conversion builtins — where an `E504` warning discloses the unguarded residual rather than claiming a check the runtime never performs. **Do not** insert `nat_to_int` defensively; `@Nat` already flows to `@Int`. Keep a value that may be negative as `@Int`, or use `int_to_nat` (which returns `Option`) when an explicit narrowing must handle the failure case. See spec §2.2.1 for the formal rule. +**`Int` and `Nat` are interchangeable in both directions.** `@Nat <: @Int` is a formal subtyping rule at the *type* level (use a `@Nat` anywhere `@Int` is expected, no `nat_to_int` call), and `@Int <: @Nat` is permitted by the type checker with a verifier-discharged obligation (`@Int.0 >= 0`). This means `array_length` (declared `@Int`) flows freely into `@Nat` positions without explicit conversion — the verifier proves non-negativity from context or falls back to a runtime check. Both directions carry a *value*-level obligation, because `@Nat` is a u64 and `@Int` an i64: narrowing requires `>= 0` (`E503`/`E504`), and widening requires `<= i64.MAX` (`E530`; or an `E531` warning at the generic-instantiated `@Int`-field component site code generation cannot guard) — a `@Nat` above i64.MAX bit-reinterprets to a negative `@Int`. Runtime guards and verifier obligations correspond at every closure depth (nested closure returns included); the documented residual runs in one direction only. Obligated-but-not-guarded: three narrowing sites are statically obligated yet carry no runtime guard — a user-declared effect operation's argument and the generic-instantiated constructor field ([#754](https://github.com/aallan/vera/issues/754)/[#757](https://github.com/aallan/vera/issues/757)), and the `nat_to_int`/`nat_to_string` conversion builtins — where an `E504` warning discloses the unguarded residual rather than claiming a check the runtime never performs. The built-in effects' operation arguments are guarded: the `State` write boundaries, and the `Exn` `throw` payload, which also takes the refinement-predicate guard ([#1268](https://github.com/aallan/vera/issues/1268)). **Do not** insert `nat_to_int` defensively; `@Nat` already flows to `@Int`. Keep a value that may be negative as `@Int`, or use `int_to_nat` (which returns `Option`) when an explicit narrowing must handle the failure case. See spec §2.2.1 for the formal rule. ### Composite types @@ -2402,7 +2402,7 @@ public fn main(@Unit -> @Unit) ## Conformance Suite -The `tests/conformance/` directory contains 241 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. +The `tests/conformance/` directory contains 242 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. Each program is organized by spec chapter (`ch01_int_literals.vera`, `ch04_match_basic.vera`, `ch07_state_handler.vera`, etc.) and the `manifest.json` file maps features to programs. When you need to see how a specific construct works, check the conformance program before reading the spec. @@ -2443,7 +2443,7 @@ Current reference-implementation bugs that an agent writing Vera code is likely |---|---|---|---| | Rare conformance-gate flake | `ch05_closure_nat_return` trapped once in a full conformance run and never again (~960 clean attempts) — suspected runtime/GC timing interaction, not a compiler defect. | If CI reds on this program with `Reached unreachable` in `main`, re-run and report on the issue with wasmtime version + load conditions — do not chase the compiler. | [#996](https://github.com/aallan/vera/issues/996) | -When a Vera program type-checks cleanly, compiles without errors, and then produces a runtime trap you can't explain, runtime trap diagnostics are now Vera-native end-to-end: each trap carries a `kind` label (`divide_by_zero` / `out_of_bounds` / `stack_exhausted` / `unreachable` / `overflow` / `contract_violation` / `host_error` / `unknown`), a per-kind `Fix:` paragraph naming the canonical remediation, and a source backtrace pointing at the offending Vera function and line — not just `wasm trap: `. Tail-recursive iteration runs in constant WASM stack space for both non-allocating ([#517](https://github.com/aallan/vera/issues/517), v0.0.126) and allocating ([#549](https://github.com/aallan/vera/issues/549), v0.0.154) tail calls — the latter prepends a `$gc_sp` restore before each `return_call` to keep the shadow stack bounded across iterations. +When a Vera program type-checks cleanly, compiles without errors, and then produces a runtime trap you can't explain, runtime trap diagnostics are now Vera-native end-to-end: each trap carries a `kind` label (`divide_by_zero` / `out_of_bounds` / `stack_exhausted` / `unreachable` / `overflow` / `contract_violation` / `host_error` / `unknown`), a per-kind `Fix:` paragraph naming the canonical remediation (omitted for `contract_violation` and `host_error`, whose descriptions already carry the specific instruction, and for `unknown`, where there is nothing general to suggest), and a source backtrace pointing at the offending Vera function and line — not just `wasm trap: `. Tail-recursive iteration runs in constant WASM stack space for both non-allocating ([#517](https://github.com/aallan/vera/issues/517), v0.0.126) and allocating ([#549](https://github.com/aallan/vera/issues/549), v0.0.154) tail calls — the latter prepends a `$gc_sp` restore before each `return_call` to keep the shadow stack bounded across iterations. ## Specification Reference diff --git a/docs/index.html b/docs/index.html index 4ed15a2b..fd324e1f 100644 --- a/docs/index.html +++ b/docs/index.html @@ -699,7 +699,7 @@

This page is also a machine-readable specificati Vera is under active development

- A complete compiler with 164 built-in functions, ten algebraic effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge), contract-driven testing via Z3, a language server with agent-facing proof deltas, and a 14-chapter specification. A 241-program conformance suite and 42 worked examples are validated against the spec on every pull request. All of it is developed openly on GitHub and released under the MIT licence. + A complete compiler with 164 built-in functions, ten algebraic effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge), contract-driven testing via Z3, a language server with agent-facing proof deltas, and a 14-chapter specification. A 242-program conformance suite and 42 worked examples are validated against the spec on every pull request. All of it is developed openly on GitHub and released under the MIT licence.

diff --git a/docs/index.md b/docs/index.md index b1763be5..49454d5b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -281,7 +281,7 @@ For other models: point them at [`SKILL.md`](https://veralang.dev/SKILL.md) via ## Status -Vera is under [active development](https://raw.githubusercontent.com/aallan/vera/main/ROADMAP.md). A complete compiler with 164 built-in functions, ten algebraic effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge), contract-driven testing via [Z3](https://www.microsoft.com/en-us/research/project/z3-3/), and a 14-chapter specification. A 241-program conformance suite and 42 worked examples are validated against the spec on every pull request. All of it is developed openly on [GitHub](https://github.com/aallan/vera) and released under the MIT licence. +Vera is under [active development](https://raw.githubusercontent.com/aallan/vera/main/ROADMAP.md). A complete compiler with 164 built-in functions, ten algebraic effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge), contract-driven testing via [Z3](https://www.microsoft.com/en-us/research/project/z3-3/), and a 14-chapter specification. A 242-program conformance suite and 42 worked examples are validated against the spec on every pull request. All of it is developed openly on [GitHub](https://github.com/aallan/vera) and released under the MIT licence. ## Links diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 8dcc8ce8..2c6b265c 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -446,7 +446,7 @@ array, useful when processing diagnostics programmatically. - `Unit` — singleton type, value is `()`. Zero-size and **declaration-only**: a `@Unit` parameter (function or handler-clause op) is legal but reading it (`@Unit.0`) is a checker error (E182) — write the literal `()` instead — and a `let` of a zero-size type (`let @Unit = put(5);`) is a checker error (E183) — call the expression as a statement (`put(5);`). Applies to anything with no runtime representation, including `Future`. - `Never` — bottom type (used for non-terminating expressions like `throw`) -**`Int` and `Nat` are interchangeable in both directions.** `@Nat <: @Int` is a formal subtyping rule at the *type* level (use a `@Nat` anywhere `@Int` is expected, no `nat_to_int` call), and `@Int <: @Nat` is permitted by the type checker with a verifier-discharged obligation (`@Int.0 >= 0`). This means `array_length` (declared `@Int`) flows freely into `@Nat` positions without explicit conversion — the verifier proves non-negativity from context or falls back to a runtime check. Both directions carry a *value*-level obligation, because `@Nat` is a u64 and `@Int` an i64: narrowing requires `>= 0` (`E503`/`E504`), and widening requires `<= i64.MAX` (`E530`; or an `E531` warning at the generic-instantiated `@Int`-field component site code generation cannot guard) — a `@Nat` above i64.MAX bit-reinterprets to a negative `@Int`. Runtime guards and verifier obligations correspond at every closure depth (nested closure returns included); the documented residual runs in one direction only. Obligated-but-not-guarded: three narrowing sites are statically obligated yet carry no runtime guard — the effect-operation argument and the generic-instantiated constructor field ([#754](https://github.com/aallan/vera/issues/754)/[#757](https://github.com/aallan/vera/issues/757)), and the `nat_to_int`/`nat_to_string` conversion builtins — where an `E504` warning discloses the unguarded residual rather than claiming a check the runtime never performs. **Do not** insert `nat_to_int` defensively; `@Nat` already flows to `@Int`. Keep a value that may be negative as `@Int`, or use `int_to_nat` (which returns `Option`) when an explicit narrowing must handle the failure case. See spec §2.2.1 for the formal rule. +**`Int` and `Nat` are interchangeable in both directions.** `@Nat <: @Int` is a formal subtyping rule at the *type* level (use a `@Nat` anywhere `@Int` is expected, no `nat_to_int` call), and `@Int <: @Nat` is permitted by the type checker with a verifier-discharged obligation (`@Int.0 >= 0`). This means `array_length` (declared `@Int`) flows freely into `@Nat` positions without explicit conversion — the verifier proves non-negativity from context or falls back to a runtime check. Both directions carry a *value*-level obligation, because `@Nat` is a u64 and `@Int` an i64: narrowing requires `>= 0` (`E503`/`E504`), and widening requires `<= i64.MAX` (`E530`; or an `E531` warning at the generic-instantiated `@Int`-field component site code generation cannot guard) — a `@Nat` above i64.MAX bit-reinterprets to a negative `@Int`. Runtime guards and verifier obligations correspond at every closure depth (nested closure returns included); the documented residual runs in one direction only. Obligated-but-not-guarded: three narrowing sites are statically obligated yet carry no runtime guard — a user-declared effect operation's argument and the generic-instantiated constructor field ([#754](https://github.com/aallan/vera/issues/754)/[#757](https://github.com/aallan/vera/issues/757)), and the `nat_to_int`/`nat_to_string` conversion builtins — where an `E504` warning discloses the unguarded residual rather than claiming a check the runtime never performs. The built-in effects' operation arguments are guarded: the `State` write boundaries, and the `Exn` `throw` payload, which also takes the refinement-predicate guard ([#1268](https://github.com/aallan/vera/issues/1268)). **Do not** insert `nat_to_int` defensively; `@Nat` already flows to `@Int`. Keep a value that may be negative as `@Int`, or use `int_to_nat` (which returns `Option`) when an explicit narrowing must handle the failure case. See spec §2.2.1 for the formal rule. ### Composite types @@ -2408,7 +2408,7 @@ public fn main(@Unit -> @Unit) ## Conformance Suite -The `tests/conformance/` directory contains 241 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. +The `tests/conformance/` directory contains 242 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. Each program is organized by spec chapter (`ch01_int_literals.vera`, `ch04_match_basic.vera`, `ch07_state_handler.vera`, etc.) and the `manifest.json` file maps features to programs. When you need to see how a specific construct works, check the conformance program before reading the spec. @@ -2449,7 +2449,7 @@ Current reference-implementation bugs that an agent writing Vera code is likely |---|---|---|---| | Rare conformance-gate flake | `ch05_closure_nat_return` trapped once in a full conformance run and never again (~960 clean attempts) — suspected runtime/GC timing interaction, not a compiler defect. | If CI reds on this program with `Reached unreachable` in `main`, re-run and report on the issue with wasmtime version + load conditions — do not chase the compiler. | [#996](https://github.com/aallan/vera/issues/996) | -When a Vera program type-checks cleanly, compiles without errors, and then produces a runtime trap you can't explain, runtime trap diagnostics are now Vera-native end-to-end: each trap carries a `kind` label (`divide_by_zero` / `out_of_bounds` / `stack_exhausted` / `unreachable` / `overflow` / `contract_violation` / `host_error` / `unknown`), a per-kind `Fix:` paragraph naming the canonical remediation, and a source backtrace pointing at the offending Vera function and line — not just `wasm trap: `. Tail-recursive iteration runs in constant WASM stack space for both non-allocating ([#517](https://github.com/aallan/vera/issues/517), v0.0.126) and allocating ([#549](https://github.com/aallan/vera/issues/549), v0.0.154) tail calls — the latter prepends a `$gc_sp` restore before each `return_call` to keep the shadow stack bounded across iterations. +When a Vera program type-checks cleanly, compiles without errors, and then produces a runtime trap you can't explain, runtime trap diagnostics are now Vera-native end-to-end: each trap carries a `kind` label (`divide_by_zero` / `out_of_bounds` / `stack_exhausted` / `unreachable` / `overflow` / `contract_violation` / `host_error` / `unknown`), a per-kind `Fix:` paragraph naming the canonical remediation (omitted for `contract_violation` and `host_error`, whose descriptions already carry the specific instruction, and for `unknown`, where there is nothing general to suggest), and a source backtrace pointing at the offending Vera function and line — not just `wasm trap: `. Tail-recursive iteration runs in constant WASM stack space for both non-allocating ([#517](https://github.com/aallan/vera/issues/517), v0.0.126) and allocating ([#549](https://github.com/aallan/vera/issues/549), v0.0.154) tail calls — the latter prepends a `$gc_sp` restore before each `return_call` to keep the shadow stack bounded across iterations. ## Specification Reference @@ -2487,7 +2487,7 @@ Read `SKILL.md` for the full language reference. It covers syntax, slot referenc ### Conformance programs as reference -The conformance suite in `tests/conformance/` contains 241 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-seven negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) instead must *fail* with the E-code in their `expected_error` field, at the stage their `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. +The conformance suite in `tests/conformance/` contains 242 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-seven negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) instead must *fail* with the E-code in their `expected_error` field, at the stage their `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. ### Workflow @@ -2666,9 +2666,9 @@ Each stage is a module with a single public API function (`parse_file`, `transfo pytest tests/ -v # Run all tests (see TESTING.md) pytest tests/test_conformance.py -v # Conformance suite only mypy vera/ # Type-check the compiler -python scripts/check_conformance.py # All 241 conformance programs hold (positives pass; negatives fail with their E-code) +python scripts/check_conformance.py # All 242 conformance programs hold (positives pass; negatives fail with their E-code) python scripts/check_examples.py # All 42 examples must pass -python scripts/check_corpus_canonical.py # All 290 corpus programs in canonical form +python scripts/check_corpus_canonical.py # All 291 corpus programs in canonical form ``` Test helpers follow a pattern: `_check_ok(source)` / `_check_err(source, match)` / `_verify_ok(source)` / `_verify_err(source, match)`. See existing tests for examples. @@ -2677,7 +2677,7 @@ When implementing a new language feature, write the conformance program *first* ### Invariants -- All 241 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first +- All 242 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first - All 42 examples in `examples/` must pass `vera check` and `vera verify` - `mypy vera/` must be clean - `pytest tests/ -v` must pass @@ -3222,7 +3222,7 @@ None of this is Vera-specific, but it validates the design choices. The thesis i This is a real concern. LLMs are trained on trillions of tokens of Python, TypeScript, and JavaScript. A MojoBench study (NAACL 2025) found that even fine-tuned models achieved only 30–35% improvement over base models on Mojo code generation, illustrating the cold-start problem for new languages. -Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (241 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification. +Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (242 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification. ## How does Vera compare to Dafny / Lean / Koka / F*? @@ -3265,7 +3265,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 11,600 tests, including a 241-program conformance suite +- 11,623 tests, including a 242-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/docs/llms.txt b/docs/llms.txt index 0bdc4197..2447d760 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -56,4 +56,4 @@ Current version: 0.1.11. The reference compiler is written in Python. Install th - [TESTING.md](https://raw.githubusercontent.com/aallan/vera/main/TESTING.md): Test suite architecture, coverage data, and test conventions. - [KNOWN_ISSUES.md](https://raw.githubusercontent.com/aallan/vera/main/KNOWN_ISSUES.md): Known bugs and limitations. - [CONTRIBUTING.md](https://raw.githubusercontent.com/aallan/vera/main/CONTRIBUTING.md): Contribution guidelines. -- [Conformance Suite](https://github.com/aallan/vera/tree/main/tests/conformance): 241 programs validating every language feature against the spec. +- [Conformance Suite](https://github.com/aallan/vera/tree/main/tests/conformance): 242 programs validating every language feature against the spec. diff --git a/spec/02-types.md b/spec/02-types.md index 6e288942..ad3ac3cd 100644 --- a/spec/02-types.md +++ b/spec/02-types.md @@ -300,7 +300,9 @@ An obligation drops to Tier 3 — reported as an `E506` warning rather than sile ### 2.6.5 Runtime Guards -A refinement predicate is also guarded at **runtime**: the compiler emits a predicate check at every function boundary — a refined parameter is checked at entry and a refined return at exit — that traps (via the contract-failure channel) if the value violates the predicate. So even a program compiled *without* `vera verify` rejects a refinement-violating value rather than silently accepting it; for example, calling `clamp_percent(@Int)` whose body returns a value outside `0..100` traps with a refinement-violation diagnostic. This holds at a `public`/FFI entry point too, where an untrusted caller cannot bypass the callee's entry guard. A call argument is covered by that guard, so the boundary checks compose to cover every narrowing whose result is consumed across a boundary; a purely internal narrowing (a `let`, match bind, destructure, constructor field, ADT sub-pattern bind, or effect-operation argument that never crosses a boundary) is Tier-3-static-only — surfaced as an `E506` warning, not silently accepted. +A refinement predicate is also guarded at **runtime**: the compiler emits a predicate check at every function boundary — a refined parameter is checked at entry and a refined return at exit — that traps (via the contract-failure channel) if the value violates the predicate. So even a program compiled *without* `vera verify` rejects a refinement-violating value rather than silently accepting it; for example, calling `clamp_percent(@Int)` whose body returns a value outside `0..100` traps with a refinement-violation diagnostic. This holds at a `public`/FFI entry point too, where an untrusted caller cannot bypass the callee's entry guard. A call argument is covered by that guard, so the boundary checks compose to cover every narrowing whose result is consumed across a boundary; a purely internal narrowing (a `let`, match bind, destructure, constructor field, ADT sub-pattern bind, or a *user-declared* effect operation's argument that never crosses a boundary) is Tier-3-static-only — surfaced as an `E506` warning, not silently accepted. + +The built-in `Exn` effect's `throw` payload is guarded, not internal. `throw(v)` narrows `v` into the `Exn` payload and the value leaves the throwing function, but it crosses no *function* boundary on the way, so none of the composing checks above reaches it: the compiler emits the predicate check at the `throw` itself, and a violating payload traps there rather than arriving in a handler clause that has already assumed the predicate. This covers refinements over **a base the verifier does not model** too — `{ @Array | array_length(@Array.0) > 0 }`, and equally `{ @Byte | @Byte.0 < 10 }`. The predicate translator does not substitute such a binder, so a *symbolic* narrowing is Tier 3 *statically* (§2.6.4, cause 2) even where the predicate is itself in the decidable fragment; codegen compiles it directly to WebAssembly regardless, so an empty array passed into a `@NonEmptyArray` parameter — or a `@Byte` computed at run time and passed into a `@SmallByte` one — traps at run time. A literal argument does not reach the guard: it is decided statically (§2.6.4, cause 2), so `small(200)` is rejected before it can run. diff --git a/spec/06-contracts.md b/spec/06-contracts.md index 0ca79ae4..99e92402 100644 --- a/spec/06-contracts.md +++ b/spec/06-contracts.md @@ -266,7 +266,7 @@ To discharge an operation obligation, the programmer encodes the constraint in a **Division and modulo** are Tier-1-decidable — the divisor is a concrete integer term — so an unguarded `a / b` whose Tier-1-translatable divisor admits a zero counterexample is a compile error (E526); a divisor beyond the translatable fragment (a fresh-scope slot in a closure or handler clause, an opaque effect result, a solver timeout) degrades to a runtime-guarded Tier-3 obligation instead, while a manifest zero divisor is E526 in any position. (Float division is exempt: `f64.div` by zero yields inf/NaN, not a trap.) **Array indexing** depends on `array_length`, which the SMT layer models as an *uninterpreted* function (§6.3.2), so bounds reasoning is in general beyond Tier 1. The verifier therefore tiers the obligation honestly: it proves the bound at **Tier 1** when a literal length, refinement, precondition, or path condition pins the length; reports a compile error (**E527**) when the index provably exceeds a statically-known length (e.g. `[1, 2, 3][5]`); and otherwise — a dynamic, opaque length — degrades to a runtime-guarded **Tier 3** obligation (counted in `vera verify --json`, never a silent pass). An index inside a closure body, quantifier predicate, or handler-clause body is walked under a fresh (empty) slot scope; one that depends on a captured length or fresh slot is reported as a runtime-guarded **Tier 3** obligation — beyond the fresh scope's decidable fragment, with the codegen bounds-check backing it — while a literal-only shape still classifies exactly (a manifest out-of-bounds literal is a loud E527) — while an index in a quantifier *domain* or a handler *body* (enclosing-scope positions) is tiered at full precision like any direct-position index; lifting the fresh-scope sites to a Tier-1 proof is the Tier 2 work in [#427](https://github.com/aallan/vera/issues/427). Indexing applies to `Array` only; indexing a `String` is a type error (E161). -The `@Nat` obligations (E502 / E503) carry the most nuance, spanning many binding sites. The verifier emits an E502 obligation `lhs >= rhs` at every `@Nat - @Nat` subtraction site (see [#520](https://github.com/aallan/vera/issues/520)), and an E503 obligation `value >= 0` where an `@Int` value narrows into a `@Nat` **binding** slot — `let`, call-argument, effect-operation-argument, constructor-field, top-level match-bind, and literal-tuple-destructure sites (see [#552](https://github.com/aallan/vera/issues/552)), plus the generic-instantiation, ADT sub-pattern, non-literal-destructure, and cross-module imported-constructor sites (see [#747](https://github.com/aallan/vera/issues/747)), and the function **return** position — an `@Int` value (including an `if`/`match` tail) narrowing into a `@Nat` return is obligated `result >= 0` under the body's path conditions, the dual of the [#813](https://github.com/aallan/vera/issues/813) `@Nat -> @Int` widen-return obligation (see [#758](https://github.com/aallan/vera/issues/758)). The codegen mirrors the subtraction obligation and the `@Nat` binding and return sites with runtime guards — every concrete site (`let`, destructure, match-bind, sub-pattern, concrete constructor field, concrete call-argument) plus **generic function-formal calls**, which guard on the monomorphised callee (the mangled instance `pick$Nat` carries concrete `@Nat` flags). Two sites stay unguarded, both still obligated statically, so a Tier-3 narrowing the solver cannot discharge at either surfaces an E504 warning: the **effect-operation argument**, whose runtime guard is deferred (see [#754](https://github.com/aallan/vera/issues/754)), and the **generic-instantiated constructor field**, since constructor layouts carry no per-field `@Nat` metadata to monomorphise. Division, modulo, and array indexing now follow the same auto-synthesis pattern ([#680](https://github.com/aallan/vera/issues/680)); lifting dynamic or closure-captured array bounds from a runtime-guarded Tier 3 to a Tier-1 proof is part of the Tier 2 verification work in [#427](https://github.com/aallan/vera/issues/427). +The `@Nat` obligations (E502 / E503) carry the most nuance, spanning many binding sites. The verifier emits an E502 obligation `lhs >= rhs` at every `@Nat - @Nat` subtraction site (see [#520](https://github.com/aallan/vera/issues/520)), and an E503 obligation `value >= 0` where an `@Int` value narrows into a `@Nat` **binding** slot — `let`, call-argument, effect-operation-argument, constructor-field, top-level match-bind, and literal-tuple-destructure sites (see [#552](https://github.com/aallan/vera/issues/552)), plus the generic-instantiation, ADT sub-pattern, non-literal-destructure, and cross-module imported-constructor sites (see [#747](https://github.com/aallan/vera/issues/747)), and the function **return** position — an `@Int` value (including an `if`/`match` tail) narrowing into a `@Nat` return is obligated `result >= 0` under the body's path conditions, the dual of the [#813](https://github.com/aallan/vera/issues/813) `@Nat -> @Int` widen-return obligation (see [#758](https://github.com/aallan/vera/issues/758)). The codegen mirrors the subtraction obligation and the `@Nat` binding and return sites with runtime guards — every concrete site (`let`, destructure, match-bind, sub-pattern, concrete constructor field, concrete call-argument) plus **generic function-formal calls**, which guard on the monomorphised callee (the mangled instance `pick$Nat` carries concrete `@Nat` flags). Two sites stay unguarded, both still obligated statically, so a Tier-3 narrowing the solver cannot discharge at either surfaces an E504 warning: a **user-declared effect operation's argument**, whose runtime guard is deferred (see [#754](https://github.com/aallan/vera/issues/754)), and the **generic-instantiated constructor field**, since constructor layouts carry no per-field `@Nat` metadata to monomorphise. The built-in effects' operation arguments are guarded at their op-call sites: the `State` write boundaries (see [#1203](https://github.com/aallan/vera/issues/1203)) and the `Exn` `throw` payload, which also carries the §2.6.5 refinement-predicate guard (see [#1268](https://github.com/aallan/vera/issues/1268)). Division, modulo, and array indexing now follow the same auto-synthesis pattern ([#680](https://github.com/aallan/vera/issues/680)); lifting dynamic or closure-captured array bounds from a runtime-guarded Tier 3 to a Tier-1 proof is part of the Tier 2 verification work in [#427](https://github.com/aallan/vera/issues/427). **Integer overflow** ([#798](https://github.com/aallan/vera/issues/798)). `@Int` is a signed 64-bit machine integer and `@Nat` an unsigned one; `+` / `-` / `*` wrap at the i64 / u64 boundary. Like `@Nat` underflow and signed-division `MIN / -1`, an overflowing operation is a *partial* operation that **traps** at runtime rather than silently wrapping, so each `@Int` / `@Nat` `+` / `-` / `*` carries an obligation that the result stays in range. It is classified at the operands' **common (coerced) type** — `@Int` if either operand is `@Int` (since `@Nat <: @Int`), else `@Nat` — not one operand's self-type (a non-negative literal is `@Nat`, but `5 + @Int.0` is an i64 add) nor the possibly-narrowed result type (an `@Int.0 + 1` stored into a `@Nat` slot is still an i64 add). A two-check mirrors array indexing: the result provably in range → **Tier 1**; provably out of range (e.g. a literal `u64.MAX + 1`, or `@Int.0 + 1` under `requires(@Int.0 == i64.MAX)`) → a compile error (**E528**); otherwise — dynamic operands — a runtime-guarded **Tier 3** trap. `@Nat` subtraction is excluded — it is the underflow obligation (E502) above, never a high-overflow. diff --git a/spec/11-compilation.md b/spec/11-compilation.md index d46f0673..3b9eb432 100644 --- a/spec/11-compilation.md +++ b/spec/11-compilation.md @@ -69,7 +69,7 @@ The trap is classified as `kind="unreachable"` rather than a dedicated `kind="un Arithmetic **overflow** of `@Int`/`@Nat` addition, subtraction, and multiplication (`+`/`-`/`*`) is handled the same way ([#798](https://github.com/aallan/vera/issues/798)). Because `Int` and `Nat` share the `i64` representation, these operations wrap under two's-complement arithmetic, so each such site carries a Tier-1 proof obligation that the `result` stays in range — classified at the operands' **common (coerced) arithmetic type** (`@Int` if either operand is `@Int`, else `@Nat`; `@Nat` subtraction is excluded, being the underflow obligation above). The range checked is the **signed** 64-bit range for `@Int` and the **unsigned** 64-bit range for `@Nat` — the shared `i64` interpreted with the operation's signedness. The verifier discharges it three ways: result provably in range → **Tier 1**; provably out of range → a compile error (**E528**) raised *before* codegen; otherwise — dynamic operands — **Tier 3**. As with `@Nat` subtraction, the codegen is type-driven, not tier-driven: it emits the guarded op at every such site regardless of the verifier's tier (a Tier-1 discharge means the guard provably never fires, but it is still emitted). The overflow trap is classified `kind="overflow"`: [#808](https://github.com/aallan/vera/issues/808) wired the guard to a `vera.overflow_trap` host import (mirroring `vera.contract_fail`) that it calls immediately before its `unreachable`, so a dynamic overflow surfaces the dedicated overflow diagnostic and its Fix paragraph rather than the generic `unreachable` kind. (`@Nat` subtraction underflow keeps the bare-`unreachable` net described above — its dedicated kind is tracked separately.) The obligation stays sound because the function traps on overflow before it can return a wrapped value. -Authors lift Tier-3 functions back to Tier 1 by adding `requires lhs >= rhs` clauses. Subtraction sites that do not produce a `Nat`-typed result (e.g., `@Int - @Int`) carry no *underflow* obligation — they may produce negative values, which is well-defined for `Int` (they remain subject to the overflow obligation described above). `@Byte` arithmetic is not currently permitted by the type checker (`Byte` is excluded from `NUMERIC_TYPES` in `vera/types.py`), so the underflow obligation needs no `Byte` extension today; allowing `@Byte` arithmetic with both underflow and overflow guards is tracked speculatively as [#564](https://github.com/aallan/vera/issues/564). The verifier checks the `@Nat >= 0` invariant at subtraction sites (in any position, including a function's return expression) and at binding sites where an `@Int` value narrows into a `@Nat` slot — `let` bindings, call arguments, constructor fields, top-level match binds, and literal-tuple destructures, plus the pure-literal `let @Nat = 0 - 1` case the subtraction obligation defers ([#552](https://github.com/aallan/vera/issues/552)). [#747](https://github.com/aallan/vera/issues/747) extended the narrowing obligation to the projection and instantiation sites — ADT sub-pattern binds (`match opt { Some(@Nat) -> }` on `Option`), non-literal tuple destructures, generic constructor / effect-operation / function formals instantiated to `@Nat`, and imported ADT constructors — so every narrowing **binding site** is now statically obligated. [#758](https://github.com/aallan/vera/issues/758) extended the obligation to the function **return** position: a bare `@Int`→`@Nat` narrowing at a return slot (including an `if`/`match` tail) is obligated `result >= 0` under the body's path conditions — the dual of #813's `@Nat -> @Int` widen-return — and codegen emits the mirroring return guard so an unverified compile traps rather than returning a negative through the `@Nat` slot. The Tier-3 runtime guard backs every binding and return site except two — the effect-operation argument, and the generic-instantiated constructor field (which erases to i64 with no per-field guard) — and a tripped guard reports a generic trap rather than the `requires(... >= 0)` fix. The effect-op guard and the dedicated trap kind are tracked as [#754](https://github.com/aallan/vera/issues/754), the generic constructor field as [#757](https://github.com/aallan/vera/issues/757) (see §11.17). +Authors lift Tier-3 functions back to Tier 1 by adding `requires lhs >= rhs` clauses. Subtraction sites that do not produce a `Nat`-typed result (e.g., `@Int - @Int`) carry no *underflow* obligation — they may produce negative values, which is well-defined for `Int` (they remain subject to the overflow obligation described above). `@Byte` arithmetic is not currently permitted by the type checker (`Byte` is excluded from `NUMERIC_TYPES` in `vera/types.py`), so the underflow obligation needs no `Byte` extension today; allowing `@Byte` arithmetic with both underflow and overflow guards is tracked speculatively as [#564](https://github.com/aallan/vera/issues/564). The verifier checks the `@Nat >= 0` invariant at subtraction sites (in any position, including a function's return expression) and at binding sites where an `@Int` value narrows into a `@Nat` slot — `let` bindings, call arguments, constructor fields, top-level match binds, and literal-tuple destructures, plus the pure-literal `let @Nat = 0 - 1` case the subtraction obligation defers ([#552](https://github.com/aallan/vera/issues/552)). [#747](https://github.com/aallan/vera/issues/747) extended the narrowing obligation to the projection and instantiation sites — ADT sub-pattern binds (`match opt { Some(@Nat) -> }` on `Option`), non-literal tuple destructures, generic constructor / effect-operation / function formals instantiated to `@Nat`, and imported ADT constructors — so every narrowing **binding site** is now statically obligated. [#758](https://github.com/aallan/vera/issues/758) extended the obligation to the function **return** position: a bare `@Int`→`@Nat` narrowing at a return slot (including an `if`/`match` tail) is obligated `result >= 0` under the body's path conditions — the dual of #813's `@Nat -> @Int` widen-return — and codegen emits the mirroring return guard so an unverified compile traps rather than returning a negative through the `@Nat` slot. The Tier-3 runtime guard backs every binding and return site except two — a user-declared effect operation's argument, and the generic-instantiated constructor field (which erases to i64 with no per-field guard) — and a tripped guard reports a generic trap rather than the `requires(... >= 0)` fix. The built-in effects' operation arguments are guarded at their op-call sites: the `State` write boundaries ([#1203](https://github.com/aallan/vera/issues/1203)) and the `Exn` `throw` payload ([#1268](https://github.com/aallan/vera/issues/1268)). The remaining effect-op guard and the dedicated trap kind are tracked as [#754](https://github.com/aallan/vera/issues/754), the generic constructor field as [#757](https://github.com/aallan/vera/issues/757) (see §11.17). The **`@Nat`→`@Int` widening** direction carries the dual obligation ([#813](https://github.com/aallan/vera/issues/813)). Because `Nat` (u64) and `Int` (i64) share the `i64` representation, widening a `@Nat` whose value exceeds `i64.MAX` bit-reinterprets it to a *negative* `@Int` (`u64.MAX` → `-1`), so each `@Nat`→`@Int` coercion site carries a Tier-1 proof obligation that the value is `<= i64.MAX` (`nat_to_int_coerce`). The verifier discharges it the same three ways: provably in range → **Tier 1**; provably out of range → a compile error (**E530**) before codegen; otherwise — dynamic value — **Tier 3**. Code generation emits the matching runtime guard (the same `unreachable` net, tripping when the widened i64 reads as negative) at the sites where the source `@Nat` is statically known: the return, `let`, call-argument, concrete `@Int` constructor-field, `@Nat`-field ADT sub-pattern extraction, match-binding, array-literal element, tuple construction/destructure component, heterogeneous `if`/`match` arm (a genuine `@Int`-slot alternative makes the join `@Int`, so the `@Nat` arm is guarded per-arm), and closure argument, return, and capture positions (a captured `@Nat` widening into an `@Int` closure body shares the body-return guard) ([#820](https://github.com/aallan/vera/issues/820) threaded the checker's per-component target-type table into code generation so the erased tuple/array layouts and heterogeneous arms recover their `@Int` target; the closure positions recover it from the closure's own function type). The table-recovered component guards fire for **imported module bodies** too ([#987](https://github.com/aallan/vera/issues/987)): the checker collects each resolved module's own span-keyed target-type table (`CheckArtifacts.module_artifacts`) and threads it into code generation, so an imported function's array-element and tuple-construction widening is runtime-guarded through the import door — including transitively-reached and shadowed (`mod$…`) module bodies — exactly as the library's own `vera verify` reports the site Tier-3. Each imported body is resolved against *its* module's table, not the importer's, so a coincidental cross-file span cannot mis-key a guard (a module with no threaded table falls back to no component guard rather than risking a wrong target). Imported **generic** function bodies are guarded the same way ([#998](https://github.com/aallan/vera/issues/998)): each monomorphized clone of an imported `forall` function carries its origin module, and the clone — including the shadowed `mod$…` clones and the per-clone hoisted `where`-helpers — is compiled against that module's own table (monomorphization preserves node spans, so the template's spans key the clone's body correctly), so a concrete `Array` / `Tuple` widening inside a generic import traps at every instantiation exactly as the library's own `vera verify` classifies it Tier-3. One *component* coercion site code generation still cannot guard remains: a generic-instantiated `@Int` field (e.g. `Some(@Nat.0)` into `Option`, erased to i64 with no per-field mono metadata) — the widening there is disclosed as an unguarded **E531** warning rather than claiming a runtime check it never emits (tracked with its narrowing dual, [#757](https://github.com/aallan/vera/issues/757)). Unlike the narrowing guard (a no-op on a valid `@Nat`, so safe to apply to every `@Nat` target), the widen guard fires **only** when the source is provably `@Nat` — never a genuine `@Int`, which may be legitimately negative. @@ -623,4 +623,4 @@ Whether the prelude is compiling its own declaration of that name depends on whi ## 11.17 Limitations -The Tier-3 runtime guard for the `@Nat >= 0` narrowing invariant covers every concrete binding site, the function **return** position ([#758](https://github.com/aallan/vera/issues/758)), and generic function-formal calls (guarded on the monomorphised callee). Two binding sites stay unguarded — the effect-operation argument (guard deferred) and the generic-instantiated constructor field (constructor layouts carry no per-field `@Nat` mono metadata) — though both are still obligated statically (a negative is an E503/E504 at compile time). A tripped guard also reports a generic trap rather than the `requires(... >= 0)` fix. The effect-op-argument guard and the dedicated trap kind are tracked as [#754](https://github.com/aallan/vera/issues/754). +The Tier-3 runtime guard for the `@Nat >= 0` narrowing invariant covers every concrete binding site, the function **return** position ([#758](https://github.com/aallan/vera/issues/758)), and generic function-formal calls (guarded on the monomorphised callee). Two binding sites stay unguarded — a user-declared effect operation's argument (guard deferred) and the generic-instantiated constructor field (constructor layouts carry no per-field `@Nat` mono metadata) — though both are still obligated statically (a negative is an E503/E504 at compile time). The built-in effects' operation arguments are guarded at their op-call sites: the `State` write boundaries ([#1203](https://github.com/aallan/vera/issues/1203)) and the `Exn` `throw` payload, which takes the §2.6.5 refinement-predicate guard beside the sign pair ([#1268](https://github.com/aallan/vera/issues/1268)). A tripped sign guard reports a generic trap rather than the `requires(... >= 0)` fix. The remaining effect-op-argument guard and the dedicated trap kind are tracked as [#754](https://github.com/aallan/vera/issues/754). diff --git a/tests/conformance/ch07_exn_payload_guard.vera b/tests/conformance/ch07_exn_payload_guard.vera new file mode 100644 index 00000000..0ebbdb9d --- /dev/null +++ b/tests/conformance/ch07_exn_payload_guard.vera @@ -0,0 +1,59 @@ +-- Conformance: the Exn payload is a guarded write boundary (Chapter 2, +-- Section 2.6.5; Chapter 7, Section 7.4). Tests: `throw`'s payload takes +-- the boundary's runtime guards at the throw itself — the refinement +-- predicate for a refined payload, the `>= 0` sign check for a `@Nat` one +-- (#1268) — so a legal payload passes them and reaches the handler clause +-- that binds it at the declared type. Both throws are proved at Tier 1 +-- from their preconditions, which is exactly the case §2.6.5 calls defense +-- in depth: the guards are emitted and never reached. Their violating +-- twins trap, which no conformance level can express, so those live in +-- tests/test_exn_throw_payload_1268.py. +type Pos = { @Int | @Int.0 > 0 }; + +private fn refined_thrower(@Int -> @Int) + requires(@Int.0 > 0) + ensures(true) + effects(>) +{ + throw(@Int.0) +} + +private fn nat_thrower(@Int -> @Int) + requires(@Int.0 >= 0) + ensures(true) + effects(>) +{ + throw(@Int.0) +} + +private fn refined_caught(@Unit -> @Int) + requires(true) + ensures(@Int.result == 7) + effects(pure) +{ + handle[Exn] { + throw(@Pos) -> { @Pos.0 } + } in { + refined_thrower(7) + } +} + +private fn nat_caught(@Unit -> @Int) + requires(true) + ensures(@Int.result == 5) + effects(pure) +{ + handle[Exn] { + throw(@Nat) -> { nat_to_int(@Nat.0) } + } in { + nat_thrower(5) + } +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(@Int.result == 0) + effects(pure) +{ + refined_caught(()) - 7 + (nat_caught(()) - 5) +} diff --git a/tests/conformance/manifest.json b/tests/conformance/manifest.json index bc07a935..5989833d 100644 --- a/tests/conformance/manifest.json +++ b/tests/conformance/manifest.json @@ -1202,7 +1202,7 @@ "id": "ch07_state_clause_transform", "file": "ch07_state_clause_transform.vera", "chapter": 7, - "title": "State clause bodies execute \u2014 intrinsic-hybrid semantics (#976)", + "title": "State clause bodies execute — intrinsic-hybrid semantics (#976)", "level": "run", "spec_ref": "Section 7.5.2", "features": [ @@ -1261,9 +1261,9 @@ "id": "ch07_exn_string", "file": "ch07_exn_string.vera", "chapter": 7, - "title": "Exn effect handler \u2014 String payload", + "title": "Exn effect handler — String payload", "level": "run", - "spec_ref": "Section 7.4 Exn handler \u2014 String payload", + "spec_ref": "Section 7.4 Exn handler — String payload", "features": [ "exn_handler", "string_exception" @@ -1696,7 +1696,7 @@ "id": "ch08_transitive_module_import_base", "file": "ch08_transitive_module_import_base.vera", "chapter": 8, - "title": "Transitive import chain \u2014 base module", + "title": "Transitive import chain — base module", "level": "check", "spec_ref": "Section 8.6.4", "features": [ @@ -1708,7 +1708,7 @@ "id": "ch08_transitive_module_import_mid", "file": "ch08_transitive_module_import_mid.vera", "chapter": 8, - "title": "Transitive import chain \u2014 middle module", + "title": "Transitive import chain — middle module", "level": "verify", "spec_ref": "Section 8.6.4", "features": [ @@ -3460,5 +3460,19 @@ "array", "json_keys" ] + }, + { + "id": "ch07_exn_payload_guard", + "file": "ch07_exn_payload_guard.vera", + "chapter": 7, + "title": "The Exn payload is a runtime-guarded write boundary (#1268)", + "level": "run", + "spec_ref": "Section 2.6.5", + "features": [ + "exn_effect", + "refinement_type", + "nat_narrowing", + "runtime_guard" + ] } ] diff --git a/tests/test_closure_boundary_widths_1255_1256_1269.py b/tests/test_closure_boundary_widths_1255_1256_1269.py index e780c895..4861d7ef 100644 --- a/tests/test_closure_boundary_widths_1255_1256_1269.py +++ b/tests/test_closure_boundary_widths_1255_1256_1269.py @@ -757,11 +757,31 @@ def test_the_tag_and_the_thrown_value_agree_on_i32(self) -> None: Pinning both halves is what makes this a WIDTH-agreement test rather than "it runs" — a fix that widened the TAG to i64 would also run, and would put a Byte cell at eight bytes everywhere else. + + The value half no longer reads as adjacency. #1268 made the payload + a guarded write boundary as well as a sized one, so a REFINED payload + routes through a guard local between the literal and the `throw`; + asserting `i32.const 5\\n throw` would then be a test of where the + guard is, not of how wide the value is. What the width claim needs + is that the value the `throw` consumes is i32 by whichever route it + arrives — so the pushed operand is resolved: a literal directly, or + the guard local, whose DECLARED width is the thing checked. """ wat = _compile_ok(_THROW_REFINED_BYTE).wat tags = re.findall(r"\(tag \$exn_\S+ \(param ([^)]*)\)\)", wat) assert tags == ["i32"], tags - assert "i32.const 5\n throw $exn_" in wat, _fn_body(wat, "boom") + body = _fn_body(wat, "boom") + assert "i32.const 5" in body, body + assert "i64.const 5" not in body, body + lines = [ln.strip() for ln in body.strip().splitlines()] + throw_at = next(i for i, ln in enumerate(lines) + if ln.startswith("throw $exn_")) + pushed = lines[throw_at - 1] + if pushed.startswith("local.get "): + idx = pushed.split()[1] + assert f"(local $l{idx} i32)" in body, body + else: + assert pushed == "i32.const 5", body # ===================================================================== diff --git a/tests/test_exn_throw_payload_1268.py b/tests/test_exn_throw_payload_1268.py index 86d33b6c..c845c2dc 100644 --- a/tests/test_exn_throw_payload_1268.py +++ b/tests/test_exn_throw_payload_1268.py @@ -1,4 +1,5 @@ -"""``throw``'s payload is obligated like every other narrowing site (#1268). +"""``throw``'s payload is obligated AND guarded like every other narrowing +site (#1268). Every position a value narrows into a typed slot carries a proof obligation — ``let``, call argument, constructor field, effect-operation argument, handler @@ -15,18 +16,33 @@ table-driven fallback for it — keyed on ``expr.name == "put"``, so ``throw``, the only other bare built-in op taking an argument, stayed outside it. -Codegen emits no guard on the payload — ``throw`` lowers straight to a WASM -``throw $exn_`` with the argument on the stack — so the obligation is -unguarded (``guarded=False``) at all three arms, and its Tier-3 leg discloses -rather than claiming a runtime check it never gets. That claim is checked -against codegen here rather than asserted: the disclosure's own status is -pinned, and a run confirms the value really does pass through unchecked. +Codegen emitted no guard on the payload either — ``throw`` lowered straight to +a WASM ``throw $exn_`` with the argument on the stack — so a program +that never ran ``vera verify`` (and one whose ``E503`` its author ignored) +still delivered the violating value. It now takes the write boundary's +guards, at the op-call site beside ``put``'s: the ``@Int`` -> ``@Nat`` sign +guard, the ``@Nat`` -> ``@Int`` widening guard, and — refined FIRST, as +everywhere else — the §2.6.5 predicate guard for a refined payload, which +traps through ``$vera.contract_fail`` naming the predicate. So the obligation +is ``guarded`` at all three arms and its Tier-3 leg is counted, not disclosed. + +That claim is checked against codegen rather than asserted: each arm's status +is pinned AND a run confirms the value really is stopped, with a satisfying +twin beside it so "guarded" cannot be satisfied by a site that always traps. """ from __future__ import annotations import pytest -from tests.codegen_helpers import _run +from vera.codegen import execute +from vera.codegen.api import WasmTrapError +from tests.codegen_helpers import ( + _compile, + _compile_ok, + _run, + _run_refine_trap, + _run_trap, +) from tests.verifier_helpers import _verify, _verify_err @@ -213,17 +229,16 @@ def test_the_refined_alias_spelling_is_obligated_too(self) -> None: ] -class TestTheThrowPayloadDisclosureIsHonestAboutTheGuard: - """The verifier's `guarded=False` is checked against what codegen emits. +class TestTheThrowPayloadGuardIsRealAndItsDisclosureIsHonest: + """The verifier's `guarded=True` is checked against what codegen emits. - A unit assertion that the status is `tier3_unguarded` says what the - verifier believes; it cannot say whether the belief is true. The pair - here does: the status pins the flag, and the run pins codegen's side of - it. Were codegen to gain a payload guard, the run would trap and this - class would go red rather than leaving the verifier quietly pessimistic; - were the flag flipped without that codegen work, the status assertion - would go red rather than leaving a claimed runtime check that is not - emitted. + A unit assertion that the status is `tier3` says what the verifier + believes; it cannot say whether the belief is true. The pair here does: + the status pins the flag, and the run pins codegen's side of it. Delete + the guard emission and the run assertions go red rather than leaving a + claimed runtime check that is not emitted; flip the flag back without + removing the guard and the status assertions go red rather than leaving + the verifier quietly pessimistic about a boundary it does have. """ _UNTRANSLATABLE = """ @@ -270,23 +285,164 @@ class would go red rather than leaving the verifier quietly pessimistic; } """ - def test_an_undischargeable_payload_discloses_unguarded(self) -> None: + _REFINED_SYMBOLIC = """ +type Pos = { @Int | @Int.0 > 0 }; + +private fn boom(@Int -> @Int) + requires(true) + ensures(true) + effects(>) +{ + throw(@Int.0) +} + +public fn main(@Int -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + handle[Exn] { + throw(@Pos) -> { @Pos.0 } + } in { + boom(@Int.0) + } +} +""" + + _REFINED_OPAQUE = """ +type Pos = { @Int | @Int.0 > 0 }; + +private fn boom(@Float64 -> @Int) + requires(true) + ensures(true) + effects(>) +{ + throw(float_to_int(@Float64.0)) +} + +public fn main(@Float64 -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + handle[Exn] { + throw(@Pos) -> { @Pos.0 } + } in { + boom(@Float64.0) + } +} +""" + + _UNREFINED_INT = """ +private fn boom(@Int -> @Int) + requires(true) + ensures(true) + effects(>) +{ + throw(@Int.0) +} + +public fn main(@Int -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + handle[Exn] { + throw(@Int) -> { @Int.0 } + } in { + boom(@Int.0) + } +} +""" + + def test_an_undischargeable_payload_discloses_guarded(self) -> None: """`array_length` over a non-literal is deliberately untranslatable - (#802), so the obligation reaches Tier 3 — and must land on the - UNGUARDED leg (E504, excluded from the totals), not the - runtime-guarded one.""" + (#802), so the obligation reaches Tier 3 — and lands on the + runtime-GUARDED leg, counted in the totals, because the guard the leg + promises is emitted at the throw.""" result = _verify(self._UNTRANSLATABLE) binds = [o for o in result.obligations if o.kind == "nat_bind"] - assert [o.status for o in binds] == ["tier3_unguarded"], binds - assert [o.error_code for o in binds] == ["E504"], binds - assert result.summary.tier3_runtime == 0, result.summary + assert [o.status for o in binds] == ["tier3"], binds + assert result.summary.tier3_runtime >= 1, result.summary + + def test_the_payload_really_is_checked_at_run_time(self) -> None: + """...because codegen emits the sign guard: -5 no longer comes back + out of the `@Nat` payload, it traps. This is the measurement the + `guarded=True` above rests on.""" + _run_trap(self._SYMBOLIC, "main", [-5]) + + def test_a_satisfying_payload_still_runs(self) -> None: + """The over-refusal control for the run above. A guard that trapped + unconditionally would satisfy the trap assertion and break every + correct `throw`; the same program at a non-negative payload must + deliver it.""" + assert _run(self._SYMBOLIC, "main", [5]) == 5 + + def test_a_refined_payload_traps_naming_its_predicate(self) -> None: + """The refined arm's run: the §2.6.5 predicate guard, not the sign + guard — `0` clears the `@Int` base but violates `> 0`, so a `>= 0` + check would let it through. `_run_refine_trap` pins the + `$vera.contract_fail` channel rather than any trap.""" + _run_refine_trap(self._REFINED_SYMBOLIC, "main", [0]) + _run_refine_trap(self._REFINED_SYMBOLIC, "main", [-5]) + + def test_a_satisfying_refined_payload_still_runs(self) -> None: + """The refined arm's over-refusal control.""" + assert _run(self._REFINED_SYMBOLIC, "main", [7]) == 7 + + def test_an_unrefined_int_payload_keeps_its_negative(self) -> None: + """The TYPE gate is load-bearing: an `Exn` payload has no + non-negativity invariant to violate, so a negative one is a correct + program and must run. Guarding on the wrong type — the sign guard + applied to the `@Int` arm — turns this into a trap. + """ + assert _run(self._UNREFINED_INT, "main", [-5]) == -5 - def test_the_payload_really_is_unchecked_at_run_time(self) -> None: - """...because codegen emits no guard: -5 comes back out of the `@Nat` - payload. This is the measurement the `guarded=False` above rests on, - and the reason the refutation in the first class has to be an ERROR - rather than a deferral to a runtime check.""" - assert _run(self._SYMBOLIC, "main", [-5]) == -5 + _REFINED_STRING = """ +type Short = { @String | string_length(@String.0) < 5 }; + +private fn boom(@Int -> @Int) + requires(true) + ensures(true) + effects(>) +{ + throw(to_string(@Int.0)) +} + +public fn main(@Int -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + handle[Exn] { + throw(@Short) -> { string_length(@Short.0) } + } in { + boom(@Int.0) + } +} +""" + + def test_a_pair_payload_is_guarded_over_its_pointer(self) -> None: + """The other REPRESENTATION: a `@String`-based payload is (ptr, len) + in two locals, not one scalar, so the guard has to save both halves + and check over the ptr — the shape the lifted closure's `i32_pair` + return guard uses. A scalar-only guard emits an ill-typed body here + rather than a wrong answer, so this is the branch that would fail + loudly; the satisfying twin beside it is what shows the pair is put + back on the stack in the right order. + """ + _run_refine_trap(self._REFINED_STRING, "main", [12345678]) + assert _run(self._REFINED_STRING, "main", [1]) == 1 + + def test_a_refined_payload_discloses_guarded(self) -> None: + """The refined arm's status twin of the `@Nat` one above: an opaque + payload — `float_to_int`, which the verifier models only as an opaque + result — records `refine_bind` at the runtime-GUARDED Tier 3, counted + in the totals, because the predicate guard is emitted.""" + result = _verify(self._REFINED_OPAQUE) + binds = [o for o in result.obligations if o.kind == "refine_bind"] + assert [o.status for o in binds] == ["tier3"], binds + assert result.summary.tier3_runtime >= 1, result.summary def test_a_symbolic_payload_the_contract_bounds_proves(self) -> None: """The obligation is dischargeable, not merely loud: a `requires` @@ -320,6 +476,245 @@ def test_a_symbolic_payload_the_contract_bounds_proves(self) -> None: ], [d.description[:90] for d in result.diagnostics] +class TestTheGuardedClaimIsNeverWiderThanTheGuard: + """The three places the claim and the guard could disagree. + + A `guarded` Tier-3 is a PROMISE — "this will be checked at run time" — + and the obligation stream is the only place a reader can see it. Each + cell here is a shape where the promise and the emitted code came apart in + a direction a value oracle cannot see, because the program either never + runs or runs identically either way. + """ + + _NESTED = """ +type Pos = { @Int | @Int.0 > 0 }; + +type Tiny = { @Pos | @Pos.0 < 10 }; + +private fn boom(@Float64 -> @Int) + requires(true) + ensures(true) + effects(>) +{ + throw(float_to_int(@Float64.0)) +} + +public fn main(@Float64 -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + handle[Exn] { + throw(@Tiny) -> { @Tiny.0 } + } in { + boom(@Float64.0) + } +} +""" + + def _payload(self, base: str, spelling: str) -> str: + """A `throw` into `Exn` written bare or qualified. + + The payload is `float_to_int`'s opaque result, so the obligation + reaches Tier 3 and its GUARDEDNESS is what the status reports — + a refutation or a proof would hide the flag being compared. + """ + prelude = ("type Pos = { @Int | @Int.0 > 0 };\n\n" + if base == "Pos" else "") + body = "@Pos.0" if base == "Pos" else "nat_to_int(@Nat.0)" + return f""" +{prelude}private fn boom(@Float64 -> @Int) + requires(true) + ensures(true) + effects(>) +{{ + {spelling}(float_to_int(@Float64.0)) +}} + +public fn main(@Float64 -> @Int) + requires(true) + ensures(true) + effects(pure) +{{ + handle[Exn<{base}>] {{ + throw(@{base}) -> {{ {body} }} + }} in {{ + boom(@Float64.0) + }} +}} +""" + + def test_a_nested_refinement_payload_promises_nothing(self) -> None: + """A refinement OVER a refinement has no guard and cannot have one. + + `_refinement_guard_parts` refuses the shape outright — the outer + predicate alone would silently drop the inner membership — and says + so with a loud E618 at compile. The verifier's mirror answered + `True` for it, so `vera verify` exited 0 recording a Tier-3 that + "will be checked at run time" for a program that cannot be compiled + at all: a promise about a run that can never happen. Both halves are + pinned together, because either alone reads as consistent. + """ + result = _verify(self._NESTED) + binds = [o for o in result.obligations if o.kind == "refine_bind"] + assert [o.status for o in binds] == ["tier3_unguarded"], binds + assert result.summary.tier3_runtime == 1, result.summary + compiled = _compile(self._NESTED) + errors = [d for d in compiled.diagnostics if d.severity == "error"] + assert [d.error_code for d in errors] == ["E618"], [ + (d.error_code, d.description[:80]) for d in errors + ] + + @pytest.mark.parametrize( + ("base", "kind"), [("Nat", "nat_bind"), ("Pos", "refine_bind")], + ) + def test_the_two_spellings_record_the_same_thing( + self, base: str, kind: str, + ) -> None: + """`Exn.throw(v)` is `throw(v)`, so it must obligate identically. + + Codegen's qualified arm SYNTHESIZES a bare node and delegates to the + dispatcher that emits the guards, so both spellings are guarded — but + the verifier's `QualifiedCall` arm hardcoded `guarded=False` behind a + comment stale since [#1203], and disclosed E504/E506 for a boundary + that traps. Asserted as a differential rather than two literals: the + two spellings are ONE boundary, so the statuses must agree whatever + that agreed value is, and the run confirms which one is true. + """ + def of(result: object) -> list[tuple[str, str]]: + return [(o.kind, o.status) for o in result.obligations + if o.kind == kind] + + bare = _verify(self._payload(base, "throw")) + qualified = _verify(self._payload(base, "Exn.throw")) + assert of(bare) == of(qualified), (of(bare), of(qualified)) + assert of(bare) == [(kind, "tier3")], of(bare) + assert (bare.summary.tier3_runtime + == qualified.summary.tier3_runtime), ( + bare.summary, qualified.summary) + # ...and the guard both now claim is really emitted on both paths. + _run_trap(self._payload(base, "throw"), "main", [-5.0]) + _run_trap(self._payload(base, "Exn.throw"), "main", [-5.0]) + + def test_the_unguarded_disclosure_no_longer_names_the_throw_payload( + self, + ) -> None: + """E504's rationale listed the `Exn` `throw` payload among the sites + with no runtime guard. That is false since the guard landed, and it + contradicts the spec sentences this change amended — a reader taking + the diagnostic at its word would add a defensive check the compiler + already emits. Reached through the site that IS still unguarded, a + user-declared effect's operation argument, so the sentence is read + from a real diagnostic rather than from the source. + """ + result = _verify(""" +effect Counter { + op bump(Nat -> Unit); +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + handle[Counter] { + bump(@Nat) -> { resume(()) } + } in { + Counter.bump(array_length(string_lines("a\\nb"))); + 0 + } +} +""") + binds = [o for o in result.obligations if o.kind == "nat_bind"] + assert [o.status for o in binds] == ["tier3_unguarded"], binds + rationales = [d.rationale for d in result.diagnostics + if d.error_code == "E504"] + assert len(rationales) == 1, [d.error_code for d in result.diagnostics] + assert "throw` payload ARE" in rationales[0], rationales[0] + assert "or an Exn `throw` payload)" not in rationales[0], rationales[0] + + +class TestAProvedContractSurvivesTheThrowPayload: + """The soundness differential: verify says PROVED, so run must agree. + + The clause parameter `@Nat` is not a claim the handler makes, it is one + the verifier hands every DOWNSTREAM consumer: `is_nonneg` discharges + `ensures(@Bool.result)` at Tier 1 purely from its parameter's type, with + no `requires` doing the work. So the payload reaching it decides whether + a Tier-1 proof is worth anything. Pre-fix it was not — the run reported + ``Postcondition violation in is_nonneg`` on a postcondition `vera verify` + had just proved, which is the signature the repo's verifier-probing rule + names. Everything between the throw and the consumer is deliberately + guard-free: the argument `@Nat.0` is already `@Nat`-typed, so no call-site + narrowing guard fires, and no `nat_to_int` coercion sits in the path. + """ + + _SRC = """ +private fn boom(@Unit -> @Int) + requires(true) + ensures(true) + effects(>) +{ + throw(0 - 5) +} + +private fn is_nonneg(@Nat -> @Bool) + requires(true) + ensures(@Bool.result) + effects(pure) +{ + @Nat.0 >= 0 +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + handle[Exn] { + throw(@Nat) -> { if is_nonneg(@Nat.0) then { 1 } else { 0 } } + } in { + boom(()) + } +} +""" + + def test_the_consumers_postcondition_is_proved(self) -> None: + """Half one: `is_nonneg`'s `ensures` really is a Tier-1 proof, and it + rests on the parameter's `@Nat` invariant alone. Without this the + run below proves nothing — a postcondition the verifier had left at + Tier 3 would be entitled to fail.""" + result = _verify(self._SRC) + proved = [o for o in result.obligations + if o.kind == "ensures" and o.status == "verified" + and o.fn_name == "is_nonneg"] + assert [o.expr_text for o in proved] == ["@Bool.result"], [ + (o.fn_name, o.kind, o.status, o.expr_text) + for o in result.obligations + ] + + def test_the_proof_is_not_violated_at_run(self) -> None: + """Half two: the run. The program is refuted statically (E503 at the + throw), but `vera run` does not verify, so this is the unverified + path §2.6.5 calls defense in depth — it must stop at the boundary + that was violated, not carry -5 into a function that proved it could + not receive one. + + The assertion is on WHICH failure, not merely that one occurred: a + contract violation reaches the host through the same + ``WasmTrapError`` channel a guard trap does, so `_run_trap` alone is + satisfied by the pre-fix behaviour and would be green either way. + The pre-fix RED is a `Postcondition violation in is_nonneg` — the + proved postcondition failing — so that is what must be absent. + """ + result = _compile_ok(self._SRC) + with pytest.raises(WasmTrapError) as caught: + execute(result) + message = str(caught.value) + assert "Postcondition violation" not in message, message + assert "is_nonneg" not in message, message + + class TestABareOpArgumentIsObligatedByStructureNotByName: """The fallback is keyed on "this resolves to an effect operation". diff --git a/vera/README.md b/vera/README.md index 84e150d7..e2ee3374 100644 --- a/vera/README.md +++ b/vera/README.md @@ -152,7 +152,7 @@ execute(compile_result, ...) # → run WASM via wasmtime | `tester.py` | 1,285 | Test | Z3-guided input generation (parameter types resolved through `naming.py`; a TIER-3 target whose input constraints do not all translate is skipped naming the blocker rather than trialled, while a Tier-1-proved function is reported verified and never trialled at all), WASM execution, tier classification | `test()` | | `formatter.py` | 2,036 | Format | Canonical code formatter | `format_source()` | | `errors.py` | 813 | All | Diagnostic class, error hierarchy, error code registry | `Diagnostic`, `VeraError`, `ERROR_CODES` | -| `skip.py` | 241 | All | Codegen-internal control-flow exceptions behind structured skip diagnostics (#626) | `CodegenSkip`, `CodegenInvariantError` | +| `skip.py` | 242 | All | Codegen-internal control-flow exceptions behind structured skip diagnostics (#626) | `CodegenSkip`, `CodegenInvariantError` | | `introspect.py` | 127 | All | Payloads for `vera builtins` / `effects` / `errors --json` | `builtins_payload()`, `effects_payload()`, `errors_payload()` | | `envflags.py` | 35 | All | One truthiness rule for the `VERA_*` diagnostic flags catalogued in ENVIRONMENT.md; a leaf module (imports `os` only) so any layer can read a flag without a cycle | `flag_enabled()` | | `_since.py` | 376 | All | Best-effort `since` version attribution for built-ins, effects, abilities | | @@ -753,7 +753,7 @@ The `ERROR_CODES` dict in `errors.py` maps every code to a short description (16 ## Test Suite -Testing spans a **pytest suite** of 11,600 tests across 173 files — compiler-internals unit tests plus a **conformance suite** (241 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. +Testing spans a **pytest suite** of 11,623 tests across 173 files — compiler-internals unit tests plus a **conformance suite** (242 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. See **[TESTING.md](../TESTING.md)** for the comprehensive testing reference -- test file table, conformance suite details, compiler code coverage, language feature coverage, helper conventions, validation scripts, CI pipeline, and guidelines for adding tests. diff --git a/vera/cli.py b/vera/cli.py index 96968839..cf08bb5d 100644 --- a/vera/cli.py +++ b/vera/cli.py @@ -1328,9 +1328,11 @@ def cmd_run( ) # #516 Stage 3 (#547) — append the per-kind Fix paragraph # after the backtrace. Empty string for kinds that don't - # admit a generic suggestion (`contract_violation`, where - # the description already explains what failed; `unknown`, - # where by definition we don't know what to suggest), so + # admit a generic suggestion (`contract_violation` and + # `host_error`, where the description already explains what + # failed; `unknown`, where by definition we don't know what + # to suggest — the three empty entries in + # `_TRAP_FIX_PARAGRAPHS`), so # we suppress the block entirely in those cases — printing # an empty "Fix:" header would just be noise. # diff --git a/vera/codegen/closures.py b/vera/codegen/closures.py index dc908fe2..e3cea168 100644 --- a/vera/codegen/closures.py +++ b/vera/codegen/closures.py @@ -390,6 +390,14 @@ def _compile_lifted_closure( # int-literal → i32.const coercion inside closure bodies too. ctx.set_fn_byte_params(self._fn_byte_params) ctx.set_alias_env(self._alias_env) + # No `set_refinement_guard_emitter` here (#1268), deliberately: this + # context is built with no `effect_op_cells`, so a `throw` in a + # closure body reaches no cell and is not a write boundary the guard + # could key on — it does not compile at all today (`call target + # 'throw' not registered in this module`, a closure skip). Threading + # the op registries in is what would make the boundary real, and the + # emitter's absence then fails CLOSED at a loud skip rather than + # emitting an unguarded payload the verifier records as guarded. # #814/#774: a qualified call inside a closure body must resolve the # same way it does in a top-level body — to the module's function # (`mod$…` for a shadowed fn) and, for a shadowed imported generic, to diff --git a/vera/codegen/contracts.py b/vera/codegen/contracts.py index 59439dee..1c395343 100644 --- a/vera/codegen/contracts.py +++ b/vera/codegen/contracts.py @@ -85,12 +85,16 @@ def _refinement_guard_parts( # @Pos.0 < 10 }` where `Pos = { @Int | @Int.0 > 0 }`): the # outer guard would compile only the outer predicate and # silently DROP the inner `> 0` membership — a soundness - # hole that wrongly accepts `f(-1)`. The verifier already - # records such a narrowing as a Tier-3 E506 (its - # `_base_slot_name` returns None for a non-primitive base), - # so reject it loudly here at codegen (the "reject before - # codegen" choice) with a clean E618 — a non-zero-exit - # diagnostic, not a partial guard. Returns None after + # hole that wrongly accepts `f(-1)`. The verifier records + # such a narrowing Tier-3 and, since #1268, + # **UNGUARDED** — `_refined_boundary_codegen_guardable` bails + # on a refinement base for exactly this reason. It used to + # claim `guarded`, so `vera verify` exited 0 promising a + # runtime check for a program `vera compile` then refuses: + # a promise about a run that can never happen. Reject it + # loudly here at codegen (the "reject before codegen" + # choice) with a clean E618 — a non-zero-exit diagnostic, + # not a partial guard. Returns None after # recording the error so the helper stays total; the # recorded error fails the compile. This IS reachable. # `base` IS the inner `RefinementType` on this branch; the @@ -203,6 +207,50 @@ def _emit_refinement_check( "end", ] + def _emit_boundary_refinement_guard( + self, + ctx: WasmContext, + te: ast.TypeExpr, + value_local: int, + message_head: str, + env: WasmSlotEnv, + ) -> list[str] | None: + """The two guard halves — classify then lower — as ONE call, for a + boundary that reaches this layer from inside expression translation + (#1268). + + Every other §2.6.5 guard site is CodeGenerator code that already + holds both halves and calls them in sequence. A ``throw`` payload is + not: it is discovered mid-expression by :class:`WasmContext`, which + owns the representation decisions (which local, at what width) but + none of the lowering machinery — the string pool the trap message + interns into, the ``$vera.contract_fail`` import flag, the E617/E618 + diagnostics. So the context is handed this bound pair via + ``set_refinement_guard_emitter`` and supplies only the local. + + Returns ``None`` for an unrefined *te* and for the two refined shapes + :meth:`_refinement_guard_parts` emits no guard for — an erased + ``@Unit`` base, and a nested refinement (which it also reports as a + loud E618). The verifier's ``_refined_boundary_codegen_guardable`` + mirrors exactly that set, so a caller recording the obligation + ``guarded`` is making a claim this method keeps. The nested-refinement + half of that mirror is #1268's: it answered ``True`` there, so a + program `vera compile` refuses outright (E618) verified clean while + recording a Tier-3 runtime check that could never run. + + *message_head* is everything before the predicate in the trap text, + so the message reads in the same shape as every other boundary's + (``Refinement violation in \\n : failed``). + """ + parts = self._refinement_guard_parts(te) + if parts is None: + return None + predicate, base_name = parts + message = f"{message_head}: {ast.format_expr(predicate)} failed" + return self._emit_refinement_check( + ctx, predicate, base_name, value_local, message, env, + ) + def _resolve_type_alias(self, te: ast.TypeExpr) -> ast.TypeExpr: """Walk a ``type Foo = Bar`` alias chain to the underlying TypeExpr, applying each *generic* alias's type-argument substitution (cycle- diff --git a/vera/codegen/functions.py b/vera/codegen/functions.py index 06dbcd35..925bb30f 100644 --- a/vera/codegen/functions.py +++ b/vera/codegen/functions.py @@ -6,6 +6,7 @@ from __future__ import annotations +import functools from typing import TYPE_CHECKING, cast from vera import ast @@ -415,9 +416,13 @@ def _compile_fn( # width: `throw(5)` into `Exn<{ @Byte | … }>` put an # `i64.const` under an i32 tag and the module failed # WASM validation (#1269). + # The payload's TYPE EXPRESSION rides along (#1268): + # the throw call site guards a refined payload by + # lowering its predicate, which neither name carries. exn_cell = CellNames( family=self._family_name_te(eff.type_args[0]), base=self._family_base_te(eff.type_args[0]), + type_expr=eff.type_args[0], ) effect_ops["throw"] = ( f"$exn_{mangle_type_name(exn_cell.family)}", @@ -514,6 +519,14 @@ def _compile_fn( # return types. One value, so the alias bodies and their parameter # lists cannot be handed over half-updated (#1184 / #1208). ctx.set_alias_env(self._alias_env) + # #1268: the §2.6.5 predicate lowering, bound to THIS context, for + # the boundaries the context discovers mid-expression (a `throw` + # payload). Installed here rather than passed per call so the two + # halves of a guard — representation and lowering — cannot be paired + # with different contexts. + ctx.set_refinement_guard_emitter( + functools.partial(self._emit_boundary_refinement_guard, ctx), + ) ctx.set_closure_id_start(self._next_closure_id) ctx.set_closure_sigs(self._closure_sigs) # #814 §8.5.3: module-qualified call target table, so a ``m::f`` call diff --git a/vera/verifier.py b/vera/verifier.py index 83fa83fd..2af6f841 100644 --- a/vera/verifier.py +++ b/vera/verifier.py @@ -4389,20 +4389,29 @@ def _obligate_binding_triple( site: str, nat_guarded: bool, widen_guarded: bool, + refined_guarded: bool = False, ) -> None: """The refined-first / @Nat / widen binding-obligation triple for a single value flowing into a typed slot (#1203 — shared by the - handler state-init, state-update, get-resume, and State-put sites; - the older call-argument and constructor-field copies carry - site-specific side-table subtleties and stay inline). The refined - arm is ALWAYS unguarded: no handler boundary emits a - refined-predicate guard, so it discloses E506 honestly.""" + handler state-init, state-update, get-resume, State-put, and + `throw`-payload sites; the older call-argument and constructor-field + copies carry site-specific side-table subtleties and stay inline). + + *refined_guarded* defaults to False because most of those sites emit + sign guards only — a handler write boundary lowers no refinement + predicate, so its refined arm discloses E506 honestly. The `throw` + payload is the exception (#1268): codegen lowers the predicate there, + so that site passes True and the arm records a guarded Tier-3. The + flag is only ever an upper bound — `_check_refined_binding_obligation` + intersects it with `_refined_boundary_codegen_guardable`, so a shape + codegen emits no guard for stays unguarded whatever a caller claims. + """ refined = self._refined_binding_target(value, formal) if (refined is not None and self._narrows_into_refined(value, refined)): self._check_refined_binding_obligation( decl, value, refined, smt, slot_env, assumptions, - site=site, guarded=False, + site=site, guarded=refined_guarded, ) elif (self._nat_binding_target(value, formal) and self._narrows_into_nat(value)): @@ -4850,18 +4859,19 @@ def _walk_for_nat_binding_obligations( # keys the guard off the dispatch target's cell type), which # is why the test is the op's PARENT EFFECT and not its name # — a user effect's `put` is no more guarded than its - # `emit`. Everything else is the #754 unguarded class and - # discloses E504/E531: a user-effect op of any name (its - # handler does not compile today, E602), and `throw`, which - # lowers straight to `throw $exn_` with the payload - # on the stack and no guard anywhere on that path (measured - # by run, #1268). The refined branch is ALWAYS unguarded — - # no handler or throw boundary emits a refined-predicate - # guard (only sign-bit pairs) — so it discloses E506 - # honestly. A `resume` value is obligated from the - # HandleExpr arm instead, where the clause's effect identity - # is known; `resume` resolves to no operation here, so the - # two cannot both fire. + # `emit`. `Exn`'s `throw` joined `State` at the guarded end + # in #1268: its payload now takes the same sign guards at the + # op-call site plus the §2.6.5 predicate guard for a REFINED + # payload, so it is the one op whose refined arm is guarded + # too (`refined_guarded` below). Everything else is still + # the #754 unguarded class and discloses E504/E506/E531: a + # user-effect op of any name, whose dispatch carries only a + # target (`_effect_ops`) and so reaches no cell a guard could + # be keyed on. A `resume` value is obligated from the + # HandleExpr arm + # instead, where the clause's effect identity is known; + # `resume` resolves to no operation here, so the two cannot + # both fire. op = None for eff_name in reversed(self._walk_handled_effects): info = self.env.lookup_effect(eff_name) @@ -4871,14 +4881,20 @@ def _walk_for_nat_binding_obligations( if op is None: op = self.env.lookup_effect_op(expr.name) if op is not None: - op_guarded = op.parent_effect == "State" - op_site = ("State-op argument" if op_guarded + op_guarded = op.parent_effect in ("State", "Exn") + op_site = ("State-op argument" + if op.parent_effect == "State" else "effect-operation argument") for arg in expr.args: self._obligate_binding_triple( decl, arg, None, smt, slot_env, assumptions, site=op_site, nat_guarded=op_guarded, widen_guarded=op_guarded, + # #1268: only the `throw` payload boundary lowers + # a refinement predicate. The State write + # boundaries emit sign guards alone, so their + # refined arm stays honestly unguarded. + refined_guarded=op.parent_effect == "Exn", ) if param_types is not None: # A generic function whose `TypeVar` formal is fixed to @Nat @@ -5044,6 +5060,27 @@ def _walk_for_nat_binding_obligations( op = self.env.lookup_effect_op(expr.name, qualifier=expr.qualifier) param_types = getattr(op, "param_types", None) if param_types is not None: + # Guardedness is the BARE arm's rule, on the same key (#1268). + # The QUALIFIED spelling is the same operation at the same + # boundary — codegen's `State.put` / `Exn.throw` arms + # synthesize a bare node and delegate to the very dispatcher + # that emits the guards — so the two spellings must record + # identical statuses or the obligation stream says the + # boundary moved when only the syntax did. This arm still + # said "codegen does NOT yet guard effect-op arguments", + # stale since #1203 gave `State`'s write boundaries their + # guards and false again since #1268 gave `throw`'s payload + # the sign pair AND the predicate guard: `Exn.throw(v)` was + # guarded at run time and disclosed E504/E506 as if it were + # not. The qualifier NAMES the effect, so `op.parent_effect` + # is exactly the bare arm's resolved answer with no + # innermost-handler search needed. Everything else stays the + # #754 unguarded class (`IO.sleep`'s `@Nat` formal, a + # user-declared effect's op) and discloses honestly. + op_effect = getattr(op, "parent_effect", None) + op_guarded = op_effect in ("State", "Exn") + op_site = ("State-op argument" if op_effect == "State" + else "effect-operation argument") # A concretely-@Nat formal obligates directly (#552); a # generic (TypeVar) formal — `E.wait` instantiated as # `E` — is resolved via the checker's recorded @@ -5058,18 +5095,18 @@ def _walk_for_nat_binding_obligations( and self._narrows_into_refined(arg, refined_target)): self._check_refined_binding_obligation( decl, arg, refined_target, smt, slot_env, - assumptions, site="effect-operation argument", - guarded=False, + assumptions, site=op_site, + # Only the `throw` payload boundary lowers a + # refinement predicate (#1268); the State write + # boundaries emit sign guards alone, so their + # refined arm stays honestly unguarded. + guarded=op_effect == "Exn", ) elif (self._nat_binding_target(arg, formal) and self._narrows_into_nat(arg)): self._check_nat_binding_obligation( decl, arg, smt, slot_env, assumptions, - site="effect-operation argument", - # codegen does NOT yet guard effect-op arguments - # (#754), so an untranslatable narrowing here is - # unguarded regardless of formal concreteness. - guarded=False, + site=op_site, guarded=op_guarded, ) for arg in expr.args: self._walk_for_nat_binding_obligations( @@ -6421,12 +6458,13 @@ def _report_int_widen_unguarded( "Codegen runtime-guards every concrete @Int " "coercion site (return, let, call-argument, constructor field, " "tuple component, array element, heterogeneous arm, closure " - "argument/return/capture) but not this one — an " - "effect-operation argument (a user-declared effect's " - "operation, or an Exn `throw` payload), a tuple-destructure " - "component, or a generic-instantiated @Int field with no " - "per-field mono metadata — so here the widening is neither " - "statically proven nor runtime-checked." + "argument/return/capture) but not this one — a " + "USER-declared effect operation's argument (the built-in " + "`State` write boundaries and the `Exn` `throw` payload ARE " + "guarded), a tuple-destructure component, or a " + "generic-instantiated @Int field with no per-field mono " + "metadata — so here the widening is neither statically " + "proven nor runtime-checked." ), spec_ref='Chapter 11, Section 11.2.1 "Nat as i64"', error_code="E531", @@ -7756,11 +7794,11 @@ def _report_nat_binding_unguarded( "Codegen runtime-guards " "the concrete @Nat binding sites (let, destructure, match, " "sub-pattern, concrete field) and all call-arguments (generic " - "ones on the monomorphised callee) but not this one — an " - "effect-operation argument (a user-declared effect's " - "operation, or an Exn `throw` payload), or a " - "generic-instantiated constructor field with no per-field " - "mono metadata — so here " + "ones on the monomorphised callee) but not this one — a " + "USER-declared effect operation's argument (the built-in " + "`State` write boundaries and the `Exn` `throw` payload ARE " + "guarded), or a generic-instantiated constructor field with " + "no per-field mono metadata — so here " "the narrowing is neither statically proven nor " "runtime-checked." ), @@ -9096,11 +9134,13 @@ def _refined_boundary_codegen_guardable(ty: Type) -> bool: conditions (``vera/codegen/contracts.py``); KEEP IN SYNC (#1036). Codegen bails (emits NO guard) when (a) the base is the erased - ``@Unit`` (no local to check — the ``_is_unit_refinement`` case), or - (b) the base carries a NON-PLAIN type argument — a nested refinement - or fn type, e.g. ``Array<{ @Int | ... }>`` — whose binder slot name - cannot be spelt. A ``guarded=True`` Tier-3 for either was an - unfulfilled runtime-guard promise: an empty array flowed through a + ``@Unit`` (no local to check — the ``_is_unit_refinement`` case), + (b) the base is itself a REFINEMENT, which codegen refuses outright + rather than emitting a partial guard, or (c) the base carries a + NON-PLAIN type argument — a nested refinement or fn type, e.g. + ``Array<{ @Int | ... }>`` — whose binder slot name cannot be spelt. + A ``guarded=True`` Tier-3 for any of them was an unfulfilled + runtime-guard promise: an empty array flowed through a NonEmpty-refined closure boundary silently while the obligation stream claimed a runtime check (PR #1034 adversarial review). Plain named args (``Array``, nested ``Array>`` via the @@ -9112,6 +9152,18 @@ def _refined_boundary_codegen_guardable(ty: Type) -> bool: # erases exactly like bare `@Unit` (#841), so neither has a # local for the guard to check (PR #1034 full review). return False + if isinstance(ty.base, RefinedType): + # Refinement OVER a refinement (`type Tiny = { @Pos | @Pos.0 < 10 }` + # where `Pos = { @Int | @Int.0 > 0 }`). `_refinement_guard_parts` + # answers None for this shape — it records a loud E618 and emits + # nothing, because the outer guard alone would silently drop the + # inner membership predicate. This mirror claimed True anyway, so + # `vera verify` exited 0 recording a Tier-3 that "will be checked + # at run time" for a program `vera compile` then REFUSES: a + # promise about a runtime that cannot be reached at all. The + # honest answer is unguarded (the obligation discloses E506) and + # E618 still refuses at compile. + return False if isinstance(ty.base, AdtType): return all( isinstance(arg, (PrimitiveType, AdtType)) diff --git a/vera/wasm/calls.py b/vera/wasm/calls.py index 45dbd29b..78263610 100644 --- a/vera/wasm/calls.py +++ b/vera/wasm/calls.py @@ -537,8 +537,9 @@ def _translate_call( # not — while an int literal defaults to `i64.const`, so # `throw(5)` into `Exn<{ @Byte | … }>` emitted a module WASM # validation rejects at load. Same marking, same derivation, - # different op; the #1268 narrowing GUARD on this payload is a - # separate obligation and is deliberately NOT added here. + # different op — and, since #1268, the same narrowing GUARDS + # below: the payload is a write boundary in the full sense, not + # only in its width. is_exn_throw = ( call.name == "throw" and len(call.args) == 1 and cell is not None @@ -578,7 +579,27 @@ def _translate_call( if arg_instrs is None: return None instructions.extend(arg_instrs) - if is_state_put: + # The write boundary's guards. `throw` joined `put` here in + # #1268: its payload narrows into the `Exn` slot exactly as + # `put`'s argument narrows into the cell, but it crossed no + # function boundary, so none of §2.6.5's composing guards covered + # it — `throw(0 - 5)` into an `Exn` ran to completion and + # handed `-5` to a clause that had assumed non-negativity, and a + # `@Nat`-typed consumer's Tier-1-PROVED `ensures` then failed at + # run time. The three arms mirror the verifier's + # `_obligate_binding_triple` one-for-one, refined FIRST for the + # same reason it is: the refinement's own predicate carries the + # base's implicit range (`_refinement_guard_parts` conjoins it), + # so the sign guards would be redundant under it, and running + # them instead of it would check `>= 0` where the boundary + # invariant is `> 0`. + refined_payload: ast.TypeExpr | None = None + if is_exn_throw and cell is not None: + refined_payload = self._refined_exn_payload_type(cell, call) + if refined_payload is not None: + instructions = self._emit_exn_payload_refine_guard( + instructions, refined_payload, cell, call, env) + if refined_payload is None and (is_state_put or is_exn_throw): if base == "Nat" and self._narrows_into_nat(call.args[0]): instructions = self._emit_nat_bind_guard(instructions) elif base == "Int" and self._result_is_nat(call.args[0]): diff --git a/vera/wasm/calls_handlers.py b/vera/wasm/calls_handlers.py index bfc0a5b5..32fd47bb 100644 --- a/vera/wasm/calls_handlers.py +++ b/vera/wasm/calls_handlers.py @@ -7,11 +7,11 @@ from __future__ import annotations -from typing import ClassVar +from typing import Callable, ClassVar from dataclasses import fields, is_dataclass -from vera import ast +from vera import ast, naming from vera.monomorphize import mangle_type_name from vera.slots import effect_op_result_names, type_expr_slot_name from vera.skip import STATE_CLAUSE_INLINE_DEPTH_CAP, CodegenSkip @@ -67,6 +67,10 @@ class CallsHandlersMixin: _pushed_cell_families: list[str] _addressable_from: int _clause_inline_depth: int + _refinement_guard_emitter: ( + Callable[[ast.TypeExpr, int, str, WasmSlotEnv], list[str] | None] + | None + ) # ----------------------------------------------------------------- # Ability operation dispatch: show and hash (§9.8) @@ -2215,6 +2219,113 @@ def total_count(node: object) -> int: return (self._tail_resume_arg(body) is not None and total_count(body) == 1) + def _refined_exn_payload_type( + self, cell: CellNames, call: ast.FnCall, + ) -> ast.TypeExpr | None: + """This ``throw``'s payload type when it is a REFINEMENT, else None + (#1268). + + The refined-first branch selector at the throw write boundary, asked + BEFORE a guard local is allocated so an unrefined payload's WAT stays + byte-identical to the pre-#1268 output. It is the same + :mod:`vera.naming` derivation :meth:`_refinement_guard_parts` + resolves through — the naming layer answers "is this a refinement, + and over what binder", and codegen layers its representation + decisions on top — so this cannot select an arm the emitter then + disagrees with. + + A ``throw`` cell with no payload type expression fails CLOSED. Both + producers (the declared-effect row in ``codegen/functions.py`` and + the ``handle[Exn]`` body below) thread it; a producer that forgot + to would otherwise emit no guard silently while the verifier went on + recording one — the exact false-``guarded`` claim this issue was. + """ + if cell.type_expr is None: + raise CodegenSkip( # pragma: no cover — defensive + call, + f"Exn<{cell.family}> payload carries no type expression, so " + "its refinement predicate cannot be guarded at the throw", + ) + if naming.refinement_binder_parts( + cell.type_expr, self._alias_env) is None: + return None + return cell.type_expr + + def _emit_exn_payload_refine_guard( + self, value: list[str], payload_te: ast.TypeExpr, cell: CellNames, + call: ast.FnCall, env: WasmSlotEnv, + ) -> list[str]: + """Wrap *value* with the §2.6.5 predicate guard for a refined + ``Exn`` payload (#1268) — the refined twin of the sign guards + ``_emit_nat_bind_guard`` / ``_emit_int_widen_guard`` give the + unrefined payload at the same call site. + + ``throw(v)`` narrows *v* into the payload slot exactly as a call + argument narrows into a refined formal, but the payload crosses no + function boundary, so none of §2.6.5's composing boundary guards + covers it: pre-fix, ``throw(0 - 5)`` into an ``Exn<{ @Int | @Int.0 > + 0 }>`` ran to completion and handed ``-5`` to a clause that had + assumed the predicate. This is that boundary's own guard — save the + value, test the predicate over it, push it back — so it traps through + the same ``$vera.contract_fail`` channel a refined parameter does. + + Emitted UNGATED for every refined payload, matching the closure + return guard rather than the sign guards' narrowing test: a value + already typed at the refinement satisfies its own predicate, so the + guard costs a dead check at worst, while a missing one is a false + ``guarded`` claim in the obligation stream. The verifier's mirror is + ``_refined_boundary_codegen_guardable``, which downgrades exactly the + shapes the emitter answers ``None`` for (an erased ``@Unit`` base, a + nested refinement), so obligation and guard stay in lock-step. + + Called only with the *payload_te* :meth:`_refined_exn_payload_type` + returned, which is the same expression ``cell.type_expr`` holds. + """ + emitter = self._refinement_guard_emitter + if emitter is None: + raise CodegenSkip( # pragma: no cover — defensive + call, + "no refinement-guard emitter is installed on this " + f"translation context, so the refined Exn<{cell.family}> " + "payload cannot be guarded at the throw", + ) + # The payload's SOURCE spelling, not `cell.family`: a refined + # family renders its own predicate (#1218), so naming the cell that + # way printed the predicate twice in one two-line message, once as + # the "type" and again as the thing that failed. + head = ( + f"Refinement violation in " + f"throw({ast.format_type_expr(payload_te)})\n" + " payload" + ) + if self._is_pair_type_name(cell.base): + # A `String`-based payload is (ptr, len) in two CONSECUTIVE + # locals, checked over the ptr — the same shape the lifted + # closure's i32_pair return guard uses. + ptr_local = self.alloc_local("i32") + len_local = self.alloc_local("i32") + guard = emitter(payload_te, ptr_local, head, env) + if guard is None: + return value + return [ + *value, + f"local.set {len_local}", + f"local.set {ptr_local}", + *guard, + f"local.get {ptr_local}", + f"local.get {len_local}", + ] + value_local = self.alloc_local(self._type_name_to_wasm(cell.base)) + guard = emitter(payload_te, value_local, head, env) + if guard is None: + return value + return [ + *value, + f"local.set {value_local}", + *guard, + f"local.get {value_local}", + ] + def _translate_handle_exn( self, expr: ast.HandleExpr, env: WasmSlotEnv, ) -> list[str] | None: @@ -2324,7 +2435,12 @@ def _translate_handle_exn( self._effect_ops = {**saved_ops, "throw": (tag_name, False)} self._effect_op_cells = { **saved_cells, - "throw": CellNames(family=family, base=family_base), + # `type_arg` rides along for the same reason the two names do + # (#1268): a `throw` in this body guards a refined payload by + # lowering the predicate, which only the type expression carries. + "throw": CellNames( + family=family, base=family_base, type_expr=type_arg, + ), } # Compile body diff --git a/vera/wasm/context.py b/vera/wasm/context.py index 47278e53..2355ba26 100644 --- a/vera/wasm/context.py +++ b/vera/wasm/context.py @@ -428,6 +428,26 @@ def __init__( # swapped independently and fall out of step (the #1184 mispairing). # Seeded empty; codegen calls `set_alias_env` before translation. self._alias_env: AliasEnv = EMPTY_ALIAS_ENV + # #1268: lower a refinement predicate to a boundary guard over a + # value already in a local. Injected by codegen via + # `set_refinement_guard_emitter`, because the two halves of a §2.6.5 + # guard live on opposite sides of this seam: the REPRESENTATION half + # — which local, at what width, in what order relative to the value + # on the stack — is this context's, while lowering it needs the string + # pool's trap message, the `$vera.contract_fail` import flag and the + # E617/E618 diagnostics, all of which are the generator's. The same + # injection shape as `set_adt_eq_derivable`. `None` until installed: + # a context translating a `throw` without it FAILS CLOSED (a loud + # skip), never silently unguarded — see + # `_emit_exn_payload_refine_guard`. A lifted-closure context is + # deliberately left at `None`: it carries no `effect_op_cells`, so no + # `throw` there is a write boundary this could guard (it does not + # compile at all today), and the closed failure is what a future + # thread-through would meet rather than a silently unguarded payload. + self._refinement_guard_emitter: ( + Callable[[ast.TypeExpr, int, str, WasmSlotEnv], list[str] | None] + | None + ) = None # Closure signature registry: sig_key -> (type_name, param/result WAT) self._closure_sigs: dict[str, str] = {} # Flags for resource requirements detected during translation @@ -629,6 +649,26 @@ def set_alias_env(self, env: AliasEnv) -> None: """ self._alias_env = env + def set_refinement_guard_emitter( + self, + emitter: Callable[ + [ast.TypeExpr, int, str, WasmSlotEnv], list[str] | None + ], + ) -> None: + """Install the §2.6.5 refinement-predicate guard lowering (#1268). + + *emitter* takes ``(type_expr, value_local, message, env)`` and returns + the WAT that traps via ``$vera.contract_fail`` when the value in + *value_local* violates *type_expr*'s predicate — or ``None`` when the + type is unrefined, or refined over a base codegen emits no guard for + (an erased ``@Unit``, a nested refinement). Codegen binds it to + ``CodeGenerator._emit_boundary_refinement_guard`` for THIS context, so + the trap message interns into the shared string pool and the + contract-fail import flag is raised on the generator that assembles + the module. + """ + self._refinement_guard_emitter = emitter + def set_closure_id_start(self, start: int) -> None: """Set the starting closure ID for this context.""" self._next_closure_id = start diff --git a/vera/wasm/helpers.py b/vera/wasm/helpers.py index a924269d..1e81b372 100644 --- a/vera/wasm/helpers.py +++ b/vera/wasm/helpers.py @@ -98,10 +98,21 @@ class CellNames: the family — the seam #1233's round-5 review found re-mangling an already-mangled name at — and it is gone: one canonical family is threaded to both consumers. + + *type_expr* is the cell type as it was WRITTEN, carried for the one + question neither name can answer: a refined cell's PREDICATE (#1268). + ``family`` renders it and ``base`` strips it, but the #1268 payload guard + has to LOWER it, so the guard reads the type expression its producer + already held rather than parsing a predicate back out of a mangled family + — the second-derivation trap #1218/#1233 closed everywhere else. Excluded + from equality (``compare=False``): a cell's identity is its family, and a + ``TypeExpr`` carries source spans, so comparing it would make two cells of + one family differ by where each was written. """ family: str base: str + type_expr: ast.TypeExpr | None = field(default=None, compare=False) # ===================================================================== From 059e7158b500cdb09a604c177bb13b8fb053dbff Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 18:34:25 +0100 Subject: [PATCH 47/69] Close the review's third finding, and refute its second MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The qualified effect-op arm obligated two of its three arms. PR #1325's review found `_walk_for_nat_binding_obligations`' `QualifiedCall` branch hand-written as a refined-then-`@Nat` chain with no `@Nat` -> `@Int` widening case at all, so `State.put(@Nat.0)` and `Exn.throw(@Nat.0)` into an `@Int` cell recorded NO obligation whatever — while codegen emitted the widening guard on both spellings, the qualified forms synthesizing a bare node and delegating to the dispatcher that emits it. A guard the obligation stream never mentions is the mirror image of the claim without a guard this issue started from: `verify --json` is the only place a reader sees either. Measured as a differential before the fix — bare `nat_to_int_coerce tier3` against qualified `[]`, for both ops — with the guard confirmed present in the emitted WAT of all four. The arm now routes through the shared `_obligate_binding_triple`, so the three arms cannot drift apart again by omission, and the two spellings of one operation record identical statuses. The TESTING.md pytest-invocation comment kept #1318's `1,190` while the union updated 238 to 239 entries; collection says 1,195, which is what the oracle-gated row three hundred lines above already said. The review's rooting finding is REFUTED, and the evidence is recorded in the report rather than in code: its premise is that the pair payload pointer "exists only in `ptr_local`", which the emitted WAT contradicts — a parameter payload is shadow-pushed by the GC prologue before any body code, and a freshly allocated one is pushed at its own `$alloc` site, with `$gc_sp` lowered only in the function epilogue, after the throw. The collector is mark-sweep and never moves objects, so a second root of the same pointer value would add nothing: rooting decides liveness, and liveness is already established by the producer's push. Co-Authored-By: Claude --- CHANGELOG.md | 2 +- TESTING.md | 2 +- tests/test_exn_throw_payload_1268.py | 108 +++++++++++++++++++++++++++ vera/verifier.py | 53 +++++++------ 4 files changed, 139 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 322a7157..39848d47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - **Two imports supplying one bare name are refused, in every namespace** ([#1304](https://github.com/aallan/vera/issues/1304)). Spec §8.5 ordered a local declaration against an import (§8.5.2) and gave the module-qualified form for reaching what a clash hides (§8.5.3), but defined no order between two *imports* that both supply one name. Neither did the implementation, and the gap was reachable: a module importing two dependencies that each export `forall fn gen` — one returning `@Int`, one `@Bool` — bound its bare call to whichever supplier a set of module paths happened to yield first, so one unchanged file was `vera check`-green on one run and `[E121] body has type Bool` on the next. Measured at the branch point across eight consecutive runs and eight hash seeds: accepted on seeds 0, 2 and 3, rejected on 1, 4, 5, 6 and 7, with the winner tracking module-name hash order rather than which import is written first. Codegen's E608 rail caught the *entry-visible* pair before it could matter there; the flap lived in the shapes the rail only reached at compile, from inside a module the entry program merely imports. Spec §8.5.2.2 now states the rule — a program **MUST NOT** leave a namespace with two imports supplying one bare function name — and the checker enforces it as **E155**, a check-phase code for a scope question that had been enforced by a codegen rail at the wrong layer. Refusing is what removes the flap rather than merely labelling it: with no pick to make, there is no iteration order left to expose, which a deterministic first-wins order would not have achieved (it would make the resolved declaration implicit in import sequence, §0.2.2, and let a library *adding* an export silently rebind a downstream bare call). The refusal is **definition-gated**, matching the rail it generalises: it fires because the import pair exists, not because a body names it, so an entry program importing two suppliers and never calling either is refused exactly as E608 already refused it, and rewriting a bare call in module-qualified form does not lift it. Two shapes clear it, both exercised through to their runtime value: a **local declaration** of the name (§8.5.2 — every bare call is then the local one, and each import stays reachable through `dep::name(...)`), or a **selective import** narrowing the other module's list. The ambiguity predicate is the one `namespace_fn_names` already derived for #1281 and #1299, now exposed per namespace as well as unioned, so the layer that refuses early and the layer that backstops it cannot disagree about which shape is ambiguous; the E608 condition keeps its cell, driven through a door that bypasses the checker. An ambiguous name is not injected into the type environment at all — reporting the clash while binding one supplier would leave the follow-on diagnostics keyed to whichever module the injection loop reached first, which is the nondeterminism the refusal exists to remove — so a bare call to it misses with an ordinary `E200` instead — an E-coded diagnostic emitted at **warning** severity, which the `--json` envelope reports in `warnings` rather than `diagnostics` and which does not fail the check on its own (measured: a program whose only diagnostic is `E200` reports `ok: true` and exits 0). The W-series is the separate `W001`/`W002` code namespace, and this is not one of them. **The data namespaces flapped the same way and are folded in.** Spec §8.5.4 gives constructor names the same shadowing rules as function names, which a function-only refusal would have made false: two modules each exporting a `public data Shape` with different constructor field types type-checked on some hash seeds and reported `[E213]` on others (accepted on seeds 2, 8, 9, 10 and 11; rejected on 0, 1, 3, 4, 5, 6 and 7), and the accepting seeds were the worse half — `check` **and** `verify` both passed, and the program died at `run` with an `E609` located at line 0 of the entry file, naming two modules the entry never imported. Data types are now **E156** and constructors **E157**, one code per declaration namespace exactly as codegen splits E608/E609/E610, and reported independently because they come apart: two modules exporting differently-named types that share a constructor name clash on the constructor alone. Their remedy differs from the function one and says so — E609/E610 refuse two modules' same-named data declarations by DECLARATION, consulting neither visibility nor the importer's filter nor local shadowing (the relaxation E608 received in [#1281](https://github.com/aallan/vera/issues/1281) has no data-side twin), so narrowing an import or shadowing the name locally leaves the program `E609` at compile. Both were measured against the fixture and both fail — as does marking one declaration `private` — so the two diagnostics prescribe renaming, and a cell pins that measurement so the fix text cannot drift into offering remedies that do not work. That rail over-breadth is now tracked as [#1317](https://github.com/aallan/vera/issues/1317). **A name the built-in registry already owns is not a clash** — the injection loops are `setdefault` over a `TypeEnv` the built-ins populate first, so a dependency exporting its own `option_map` never wins the bare name (measured as `E201` against the *prelude's* two-argument signature). The first cut of this refusal did not pass the built-in snapshot and reported two such dependencies as a clash, which was a new rejection rather than an earlier one; `namespace_fn_names`' claim that its ambiguity half is identical with or without the prelude argument was wrong for the same reason and is corrected, with the codegen call ordering it depends on now pinned by a cell. -- **A `throw` payload is runtime-guarded, not only obligated** ([#1268](https://github.com/aallan/vera/issues/1268)). `throw(v)` narrows `v` into the `Exn` payload, and since the static half of this issue the narrowing carries the same obligation every other binding site does — but codegen emitted no guard, so the obligation's Tier-3 leg promised a runtime check that did not exist and an unverified `vera compile`/`run` delivered the violating value anyway. `throw(0 - 5)` under `effects(>)` ran to completion and returned **-5** through the `@Nat` payload; the refined spelling (`type Pos = { @Int | @Int.0 > 0 }`) did the same. Worse than a wrong answer: a handler clause binds the payload at its declared type, so the verifier hands every downstream consumer the invariant the payload just broke — a `@Nat`-taking function discharging `ensures(@Bool.result)` at Tier 1 from its parameter's type alone reported a **postcondition violation at run time on a postcondition `vera verify` had proved**. `throw` now takes the write boundary's guards at its op-call site, beside `put`'s ([#1203](https://github.com/aallan/vera/issues/1203)): the `@Int` -> `@Nat` sign guard, the `@Nat` -> `@Int` widening guard, and — refined FIRST, as at every other narrowing site — the §2.6.5 predicate guard for a refined payload, which traps through `$vera.contract_fail` naming the predicate that failed (`Refinement violation in throw(@Pos) / payload: @Int.0 > 0 failed`). The three arms mirror the verifier's own obligation triple one-for-one, so the obligation stream and the emitted guards stay in lock-step: the payload obligation is now `guarded` at all three arms and its Tier-3 leg is counted in `tier3_runtime` rather than disclosed as `tier3_unguarded`, and the refined arm's `guarded` claim is intersected with the same `_refined_boundary_codegen_guardable` test every other refined site uses, so an erased `@Unit` base or a nested refinement — which codegen emits no guard for — stays honestly unguarded. That mirror needed one repair to be true: it answered "guarded" for a refinement OVER a refinement, which `_refinement_guard_parts` refuses outright with a loud `E618` because the outer predicate alone would silently drop the inner membership — so `vera verify` exited 0 recording a Tier-3 runtime check for a program `vera compile` then refuses, a promise about a run that can never happen. It now bails on a refinement base, and the obligation discloses `tier3_unguarded` while `E618` still refuses. The same audit found the **qualified spelling recording something different from the bare one**: the `QualifiedCall` arm hardcoded `guarded=False` behind a comment stale since [#1203](https://github.com/aallan/vera/issues/1203), so `Exn.throw(v)` — which codegen lowers by synthesizing a bare node and delegating to the very dispatcher that emits the guards — disclosed `E504`/`E506` for a boundary that traps, and `State.put(v)` had been doing the same since #1203. Both now take the bare arm's rule on the same key (`op.parent_effect`), so the two spellings of one operation record identical statuses; a user-declared effect's operation and `IO.sleep`'s `@Nat` formal stay the honest [#754](https://github.com/aallan/vera/issues/754) unguarded class. Two diagnostic rationales (`E504`, `E531`) that listed the `throw` payload among the unguarded sites — false once the guard landed, and contradicting the spec sentences this change amends — no longer do. Reaching the predicate needed the payload's TYPE, which neither of a cell's two names carries: `family` renders the predicate and `base` strips it, so `CellNames` now carries the type expression its producer already held rather than parsing one back out of a mangled family name. The predicate lowering itself is injected into the translation context (`set_refinement_guard_emitter`), because the two halves of a §2.6.5 guard sit on opposite sides of that seam — which local at what width is the context's question, while the trap message, the contract-fail import and the E617/E618 diagnostics are the generator's. An unrefined payload's WAT is byte-identical to before: a differential over all 278 pre-existing corpus programs — every `examples/` and `tests/conformance/` program, compiled and verified on both trees — moves nothing, in emitted WAT or in the obligation and diagnostic streams. +- **A `throw` payload is runtime-guarded, not only obligated** ([#1268](https://github.com/aallan/vera/issues/1268)). `throw(v)` narrows `v` into the `Exn` payload, and since the static half of this issue the narrowing carries the same obligation every other binding site does — but codegen emitted no guard, so the obligation's Tier-3 leg promised a runtime check that did not exist and an unverified `vera compile`/`run` delivered the violating value anyway. `throw(0 - 5)` under `effects(>)` ran to completion and returned **-5** through the `@Nat` payload; the refined spelling (`type Pos = { @Int | @Int.0 > 0 }`) did the same. Worse than a wrong answer: a handler clause binds the payload at its declared type, so the verifier hands every downstream consumer the invariant the payload just broke — a `@Nat`-taking function discharging `ensures(@Bool.result)` at Tier 1 from its parameter's type alone reported a **postcondition violation at run time on a postcondition `vera verify` had proved**. `throw` now takes the write boundary's guards at its op-call site, beside `put`'s ([#1203](https://github.com/aallan/vera/issues/1203)): the `@Int` -> `@Nat` sign guard, the `@Nat` -> `@Int` widening guard, and — refined FIRST, as at every other narrowing site — the §2.6.5 predicate guard for a refined payload, which traps through `$vera.contract_fail` naming the predicate that failed (`Refinement violation in throw(@Pos) / payload: @Int.0 > 0 failed`). The three arms mirror the verifier's own obligation triple one-for-one, so the obligation stream and the emitted guards stay in lock-step: the payload obligation is now `guarded` at all three arms and its Tier-3 leg is counted in `tier3_runtime` rather than disclosed as `tier3_unguarded`, and the refined arm's `guarded` claim is intersected with the same `_refined_boundary_codegen_guardable` test every other refined site uses, so an erased `@Unit` base or a nested refinement — which codegen emits no guard for — stays honestly unguarded. That mirror needed one repair to be true: it answered "guarded" for a refinement OVER a refinement, which `_refinement_guard_parts` refuses outright with a loud `E618` because the outer predicate alone would silently drop the inner membership — so `vera verify` exited 0 recording a Tier-3 runtime check for a program `vera compile` then refuses, a promise about a run that can never happen. It now bails on a refinement base, and the obligation discloses `tier3_unguarded` while `E618` still refuses. The same audit found the **qualified spelling recording something different from the bare one**: the `QualifiedCall` arm hardcoded `guarded=False` behind a comment stale since [#1203](https://github.com/aallan/vera/issues/1203), so `Exn.throw(v)` — which codegen lowers by synthesizing a bare node and delegating to the very dispatcher that emits the guards — disclosed `E504`/`E506` for a boundary that traps, and `State.put(v)` had been doing the same since #1203. Both now take the bare arm's rule on the same key (`op.parent_effect`), so the two spellings of one operation record identical statuses. The review of that fix found the arm had been hand-written as a refined-then-`@Nat` chain with **no widening branch at all**, so `State.put(@Nat.0)` / `Exn.throw(@Nat.0)` into an `@Int` cell recorded no obligation whatever while codegen emitted the `@Nat` -> `@Int` widening guard on both spellings — a guard the obligation stream never mentioned, the mirror image of the claim-without-a-guard this issue started from. It now routes through the shared `_obligate_binding_triple`, so the three arms cannot drift apart again by omission; a user-declared effect's operation and `IO.sleep`'s `@Nat` formal stay the honest [#754](https://github.com/aallan/vera/issues/754) unguarded class. Two diagnostic rationales (`E504`, `E531`) that listed the `throw` payload among the unguarded sites — false once the guard landed, and contradicting the spec sentences this change amends — no longer do. Reaching the predicate needed the payload's TYPE, which neither of a cell's two names carries: `family` renders the predicate and `base` strips it, so `CellNames` now carries the type expression its producer already held rather than parsing one back out of a mangled family name. The predicate lowering itself is injected into the translation context (`set_refinement_guard_emitter`), because the two halves of a §2.6.5 guard sit on opposite sides of that seam — which local at what width is the context's question, while the trap message, the contract-fail import and the E617/E618 diagnostics are the generator's. An unrefined payload's WAT is byte-identical to before: a differential over all 278 pre-existing corpus programs — every `examples/` and `tests/conformance/` program, compiled and verified on both trees — moves nothing, in emitted WAT or in the obligation and diagnostic streams. - **`json_parse` accepts one domain, and both runtimes accept it** ([#1306](https://github.com/aallan/vera/issues/1306)). The reference host parsed with `json.loads`, whose default `parse_constant` admits `NaN`, `Infinity` and `-Infinity`; the browser gated with `JSON.parse`, which refuses them as RFC 8259 requires. So the two hosts disagreed about *which call* rejects a non-finite value: the browser at the parse with a handled `Err`, the reference host at `json_stringify` — and there as a raw Python traceback rather than a Vera error ([#1302](https://github.com/aallan/vera/issues/1302) below). Spec §9.7.1 now states the accepted domain instead of leaving each host to inherit its parser's: RFC 8259-valid text that decodes to finite numbers and strings of Unicode scalar values, everything else `Err` at the parse with the same message on every runtime. **A non-finite number has two entry routes and the domain closes both.** The constants are one; the other is a syntactically valid number that overflows — `1e999`, `-1e999`, `[1e999]`, `{"a":1e309}` — which both host parsers accept, decoding to an infinite `JNumber` that then died at `json_stringify`, the same divergent-refusal-point defect one syntax over. RFC 8259 §6 sets no limit on a number's range and says an implementation may set one; Vera's is the finite `Float64` values, which is exactly what `json_stringify` can write back. **The refusal covers the integer spelling too**, and that half was reference-host-only: `json.loads` returns a Python `int` for a digit string with no fraction and no exponent, so `1` followed by 309 zeros never met a float range check — and then had to become an f64 at the WASM boundary, where `float()` raises. It died with `int too large to convert to float` where `JSON.parse`, which has no int/float split and sees an `Infinity` either way, returned the shared sentence. The integer bound is the double **rounding** boundary (`2**1024 - 2**970`) rather than `sys.float_info.max`, and compared in integer arithmetic: an integer *larger* than the largest finite double still rounds to it and both hosts accept it, so the obvious bound would have traded this divergence for its mirror image, and a bound implemented as `float(value)` would be the very overflow it is looking for. Underflow is not the same question and is not refused: `1e-999` decodes to `0`, finite and in the domain, pinned as a control beside `1e308` and the largest representable double so a refusal cannot generalise from "unrepresentable magnitude" to "large". Both value-level exclusions — overflow and lone surrogate — are found by ONE document-order walk returning the sentence itself, so "whichever comes first names the refusal" is the rule rather than a precedence table the two hosts could implement differently. The domain is the parse-side counterpart of the canonical output form [#1293](https://github.com/aallan/vera/issues/1293) pinned — a non-finite number has no JSON representation in either direction — and with no entry route through `json_parse`, the output-side refusal is now reachable only from a `JNumber` a program *constructed* from `nan()` or `infinity()`. The reference host's `parse_constant` hook **records rather than raises**, and the refusal is decided after the parse completes. Raising on sight would have made it answer a different question from the browser's: Python's scanner calls the hook the moment it sees the token, so `[Infinity_x]` — malformed for a reason that has nothing to do with the constant — would have reported the non-finite sentence natively while the browser reported a syntax error. Recording and continuing asks what the browser asks, by substituting `0` for each bare constant and re-parsing: *would this text be valid JSON if the constants were admitted?* Only then is the constant the whole story, and only then do both hosts say the same sentence; text malformed for any other reason keeps its host-native syntax message, as every syntax error always has. The browser's scan also only considers a token where a *value* may begin — the start of the text, or after `[`, `,` or `:`. Without that it found `NaN` at offset 1 of `-NaN`, substituted, re-parsed `-0` successfully and reported the shared sentence, where the reference host's parser never reaches the token at all and gives a syntax error. `-NaN`, `[-NaN]`, `+Infinity`, `infinity`, `nan`, `NaNx` and `-Infinityx` are all pinned as host-native on both hosts. The parity battery pins all four probe inputs from the issue's table plus the container and multi-constant shapes, compares the whole `Err` message across hosts rather than which arm was taken, and runs beside controls the refusal must not disturb — `"NaN"` as an ordinary string value among them. diff --git a/TESTING.md b/TESTING.md index b87f8849..9aff98d5 100644 --- a/TESTING.md +++ b/TESTING.md @@ -72,7 +72,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_naming_env_provenance_1208.py` | 45 | 2,097 | The other half of the #1208 contract: every consumer is handed the ENVIRONMENT the checker rendered under, not just the same renderer. Four provenance seams, each pinned by the adversarial probe that exhibited it — an IMPORTED callee's contract rendered in its DEFINING module's alias namespace (a violated precondition that vanished, and its mirror, a correct call spuriously rejected, both under a `Cnt` that names different bodies on the two sides), an imported GENERIC monomorphized and verified in that same namespace (a lying postcondition that proved clean, plus a verifier↔codegen clone differential over the recounted slot references — the desync is invisible to a unit test on either side), a `forall` variable shadowing a same-named module alias wherever a generic signature renders (the mono clone, a body `let`, the verifier's collapsed premises, and the exported uninstantiated template), and the tester's `SmtContext` holding the narrowed scope its own names were keyed in — the last one behavioural, since a generator handed the wrong scope collapses two parameters onto one variable and returns NO inputs at all. Three of the seams are also crossed against a SECOND component rather than checked for internal consistency, because a wrong-but-consistent scope is invisible from inside one: the verifier's declared parameter names against `slots.slot_table`'s, its `where`-helper scope against `slots.fn_scopes`' accumulation, and the monomorphizer's post-substitution names against what the consumers rebuild on the clone — each independent on the axis under test (which variables the two sides narrow by) but sharing `fn_slot_scope`/`slot_name` below it, so the hand-derived literal rendering beside every comparison is what a shared-renderer defect cannot satisfy. An imported generic nested under a non-generic function is pinned too — both its discovery-time recount and its verification-time clone must run in the DEFINING module's namespace. Three more seams arrived from the PR #1224 review, each with the false Tier-1 or miscompile that exhibited it: an UNPINNED callee (an imported generic's own `where`-helper, which the origin registry never pins) rendering in the module under verification rather than the entry program, whose absence let a violated precondition discharge as true and trap at run time; a callee's refined-RETURN predicate translated in the callee's namespace alongside its `requires`/`ensures`, pinned by provenance because today's bare-headed binder masks it behaviourally; and codegen's declaration-index space keyed PER NAMESPACE, without which a module's stamp turned the main file's forward alias reference into a backward one and a check-clean, verify-clean program read the wrong parameter through valid WASM. Two seams from the #1213 burndown close the same shape from the other side: the prelude's own aliases are injected only into the reserved `Vera` namespace, so no name a program can spell resolves on the codegen side alone (#1221 — the differential compares the checker's and codegen's partition of one signature, with the emitted WAT beside it), and an imported ADT is ordered at the index its OWN module gave it rather than the built-in floor (#1227), each with the control that differs by exactly the namespace under test. Controls carried alongside: the same programs with the alias renamed or the shadowing declaration removed, and the runtime oracle that shows the new E500/E501 agrees with the emitted code rather than merely reporting more | | `test_callee_contract_scope_1220_1225_1226.py` | 40 | 1,433 | A callee's contract is READ in the callee's own module. Three burndown defects, each asserted against the runtime oracle wherever the two directions of a wrong namespace (an obligation that vanishes, one that fires for no reason) look identical from inside the verifier: an E501's `Precondition:` line quoted from the file that DECLARED the clause (#1220 — in the misattribution direction too, both files carrying a plausible `requires` on the same line number, plus the imported-generic `where`-helper whose clause sits past the end of a short importer and used to quote nothing at all); a bare-name call inside an IMPORTED callee's contract resolved through the CALLEE's module registry (#1225 — the false Tier-1 whose run traps and its mirror spurious E501, through the `requires` and the `ensures` path, against a no-collision control); and the refined-RETURN binder derived through the naming layer, so a refinement over a PARAMETERISED base pushes the key its own predicate looks itself up under (#1226 — single-module and cross-module, the second proving the derivation happens INSIDE the callee scope); plus the PR #1239 review round — a module's pinned registry holds what its OWN file imports (a DEPTH-2 chain, the shape the single-level corpus could not exhibit: requires and ensures directions, bare-vs-qualified tier agreement, a name outside the middle module's import filter still missing per §8.5.1, and the mirror gate that filling the registry does not re-export), and every part of a diagnostic follows the declaring module (location, file name and excerpt, including a clause past a shorter importer's end, with a multi-line clause quoted whole), and the characterization of the binder-reference walk's one documented exception — a CLOSURE inside a predicate owns the first `@T.n` in traversal order, whose consequence is a Tier-3 demotion rather than a fact assumed about the wrong term; and the mini-review round — an obligation carries the FILE its line number belongs to, so the documented `(file, line, column)` join between the two `--json` arrays holds for a module-located obligation, with the entry-file control and warm==cold parity, and a multi-line clause is quoted with its `--` comments blanked (a `--` inside a string literal surviving, which a naive split would corrupt) | | `test_alias_application_refinement_base_1237.py` | 11 | 398 | A parameterised alias APPLICATION substitutes its arguments in the verifier's own resolver (#1237). `type Box = T;` applied as `@Box` resolved to `AdtType('T')` — the alias's binder leaking as an ADT name — so a refinement over it failed the modelled-primitive gate, the refined-return fact was dropped, and a valid program was rejected with a spurious E501 while `vera run` returned the right answer. Both halves of the fix are asserted separately: the alias body registers its own parameters as type variables (`substitute` maps type variables, so an ADT-named binder is unsubstitutable however the application side is written) and the application substitutes. Plus depth (an argument that is itself an application, and an alias whose body applies another alias), the bounded direction (a consumer wanting `>= 100` where the refinement grants `>= 18` is still rejected, and the runtime agrees), and the gate that must NOT move — an unmodelled `@Byte` base resolves correctly and still degrades to a Tier-3 runtime check, with a consumer of its predicate still refused | -| `test_exn_throw_payload_1268.py` | 32 | 848 | `throw`'s payload is obligated AND runtime-guarded like every other narrowing site (#1268). `throw(0 - 5)` under `effects(>)` verified at 4/4 Tier 1 with ZERO obligations while `vera run` returned -5 out of the `@Nat` payload — check-green, verify-green, silently wrong — because `throw` is a bare call with no function-registry entry, so the argument loop never saw it and the table-driven fallback added for the same hole at the State `put` was keyed on that one name. All three arms are asserted (the `@Nat` refutation, the refined refutation over a modelled base, and the `@Nat`->`@Int` widening obligation appearing where none existed), plus the #1251(b) concrete gate reaching the payload for free (`throw(200)` into an `Exn<{ @Byte \| @Byte.0 < 10 }>` names the value; the satisfying twin proves at Tier 1), the user-effect contrast that localized the bug (a declared op's argument was loud for the same value — both are loud now, at the same site name), and the refined-alias payload spelling. The GUARD is checked against codegen rather than asserted: an undischargeable payload must land on the runtime-guarded `tier3` leg, counted in the totals, and a run must confirm the payload really is stopped — the two together go red whichever side moves without the other (delete the emission and the runs go red; flip the flag back and the statuses do). Both arms are run at the boundary: a `@Nat` payload traps on -5 and delivers 5, a refined `{ @Int \| @Int.0 > 0 }` payload traps through the `$vera.contract_fail` channel on BOTH -5 and 0 — the value that clears the base's `>= 0` and violates the predicate, so a sign guard standing in for the predicate guard fails here — and delivers 7. The type gate carries its own over-refusal control: an `Exn` payload has no invariant to violate, so a negative one is a correct program and must still return -5. Both REPRESENTATIONS are covered — a scalar payload in one local, and a `@String`-based one whose (ptr, len) pair has to be saved in two, checked over the ptr and put back in the right order (the satisfying twin is what shows the order). The **soundness differential** is the point of the guard rather than a property of it: the clause parameter's type is what the verifier hands every downstream consumer, so a consumer discharging `ensures(@Bool.result)` at Tier 1 from its `@Nat` parameter alone is asserted PROVED and then run — pre-fix the run reported a postcondition violation on that proved postcondition, with nothing else in the path (the argument is already `@Nat`-typed, so no call-site narrowing guard fires). A dischargeable twin proves the site is not merely always-loud, and the six `Exn` conformance programs are verified whole as canaries so obligating a position that had none names itself instead of arriving as one line of a corpus sweep. The adversarial round adds the three places the `guarded` PROMISE and the emitted guard could disagree in a direction no value oracle can see, because the program either never runs or runs identically either way: a refinement OVER a refinement is asserted `tier3_unguarded` AND `E618`-refused at compile in one cell (the mirror claimed a runtime check for a program that cannot be compiled at all — either half alone reads as consistent); the bare and qualified spellings of one operation are asserted to record IDENTICAL statuses as a differential rather than two literals, on both arms, with the run confirming which value is the true one (`Exn.throw` was disclosed unguarded while codegen delegated it to the guard-emitting dispatcher, and `State.put` had been since #1203); and `E504`'s rationale is read from a real diagnostic — reached through the site that IS still unguarded, a user-declared effect's operation argument — to pin that it no longer lists the `throw` payload among the sites with no runtime guard | +| `test_exn_throw_payload_1268.py` | 38 | 956 | `throw`'s payload is obligated AND runtime-guarded like every other narrowing site (#1268). `throw(0 - 5)` under `effects(>)` verified at 4/4 Tier 1 with ZERO obligations while `vera run` returned -5 out of the `@Nat` payload — check-green, verify-green, silently wrong — because `throw` is a bare call with no function-registry entry, so the argument loop never saw it and the table-driven fallback added for the same hole at the State `put` was keyed on that one name. All three arms are asserted (the `@Nat` refutation, the refined refutation over a modelled base, and the `@Nat`->`@Int` widening obligation appearing where none existed), plus the #1251(b) concrete gate reaching the payload for free (`throw(200)` into an `Exn<{ @Byte \| @Byte.0 < 10 }>` names the value; the satisfying twin proves at Tier 1), the user-effect contrast that localized the bug (a declared op's argument was loud for the same value — both are loud now, at the same site name), and the refined-alias payload spelling. The GUARD is checked against codegen rather than asserted: an undischargeable payload must land on the runtime-guarded `tier3` leg, counted in the totals, and a run must confirm the payload really is stopped — the two together go red whichever side moves without the other (delete the emission and the runs go red; flip the flag back and the statuses do). Both arms are run at the boundary: a `@Nat` payload traps on -5 and delivers 5, a refined `{ @Int \| @Int.0 > 0 }` payload traps through the `$vera.contract_fail` channel on BOTH -5 and 0 — the value that clears the base's `>= 0` and violates the predicate, so a sign guard standing in for the predicate guard fails here — and delivers 7. The type gate carries its own over-refusal control: an `Exn` payload has no invariant to violate, so a negative one is a correct program and must still return -5. Both REPRESENTATIONS are covered — a scalar payload in one local, and a `@String`-based one whose (ptr, len) pair has to be saved in two, checked over the ptr and put back in the right order (the satisfying twin is what shows the order). The **soundness differential** is the point of the guard rather than a property of it: the clause parameter's type is what the verifier hands every downstream consumer, so a consumer discharging `ensures(@Bool.result)` at Tier 1 from its `@Nat` parameter alone is asserted PROVED and then run — pre-fix the run reported a postcondition violation on that proved postcondition, with nothing else in the path (the argument is already `@Nat`-typed, so no call-site narrowing guard fires). A dischargeable twin proves the site is not merely always-loud, and the six `Exn` conformance programs are verified whole as canaries so obligating a position that had none names itself instead of arriving as one line of a corpus sweep. The adversarial round adds the three places the `guarded` PROMISE and the emitted guard could disagree in a direction no value oracle can see, because the program either never runs or runs identically either way: a refinement OVER a refinement is asserted `tier3_unguarded` AND `E618`-refused at compile in one cell (the mirror claimed a runtime check for a program that cannot be compiled at all — either half alone reads as consistent); the bare and qualified spellings of one operation are asserted to record IDENTICAL statuses as a differential rather than two literals, on both arms, with the run confirming which value is the true one (`Exn.throw` was disclosed unguarded while codegen delegated it to the guard-emitting dispatcher, and `State.put` had been since #1203); and `E504`'s rationale is read from a real diagnostic — reached through the site that IS still unguarded, a user-declared effect's operation argument — to pin that it no longer lists the `throw` payload among the sites with no runtime guard | | `test_refinement_binder_convergence_1208.py` | 16 | 459 | Codegen's refinement boundary guard and `vera/naming.py` derive the predicate binder ONCE (#1208). A per-shape differential over a direct refinement, an alias hop, `@Nat`- and `@Byte`-based refinements (both range-conjoining), a composite base whose binder is a RESOLVED argument list, and two non-refinements that must be `None` on both sides — plus the property the convergence exists to hold, that the guard's binder equals the key a predicate's own `@Base.n` resolves to. A per-shape differential is green either way while two copies agree — which is exactly how a duplicated derivation drifts unnoticed — so the load-bearing assertion is a MUTATION: perturb `naming.refinement_binder_parts` and codegen's guard must report the perturbed binder. Codegen's two layered WASM decisions are pinned alongside — the loud E618 for a nested refinement base, reported ONCE per declaration however many call sites consult the derivation and however many clones a generic is instantiated into (two genuinely distinct sites still report twice), and no guard at all for an erased one (parametrized over `@Unit` and `Future`, the corner that erases identically but is not spelled `Unit`) — plus runtime traps proving BOTH the `@Byte` and the `@Nat` range conjunctions reach the emitted check, each pinned by trap `kind` as well as by the conjunct its message names. The mutation perturbs the predicate as well as the binder name: the two travel by different routes, and the range conjunction lives on the predicate. The once-per-site dedup is keyed on a resolved location, so a cross-module pin holds up the premise that a location carries its owning file: two imported library modules of identical shape, declaring their nested refinement at the same line and column, must produce two diagnostics attributed to two files — each quoting its own module's declaration, which is also what catches an attribution pointing past the importer's last line | | `test_checker_effects.py` | 90 | 1,455 | Effect declarations, abilities, effect subtyping, async effect, handler typing (#420 split), and the #1149 built-in-effect redeclaration gate (E152: divergent and faithful `effect IO`, codegen-only `Exn`, a registry-parametrised sweep, and a differential pinning the gate's name set to what `vera effects --json` publishes) | | `test_state_exn_registration.py` | 30 | 1296 | #1210 — State/Exn host-import registration covers the whole handler, not just its body. Four shapes, one per sub-expression position the walk used to miss (a nested handler in a clause body, in the state-init expression, in a clause's `with` update, and an `Exn` handler in a clause body), each check-green and verify-clean and therefore required to COMPILE — pre-fix every one died at whole-module WAT compilation with `unknown func` / `unknown tag`; plus the `i32_pair` cell (`handle[State]` in a `pure` function) that the walk skipped in silence, now the same loud E607 the declared-effect gate emits. The **registration-completeness differential** is the cross-component invariant itself: over every `examples/` + `tests/conformance/` program that compiles, every `state_*` / `exn_*` symbol the emitted WAT REFERENCES must have a matching import or tag DECLARATION — a desync between the registration pass and the lowering pass is invisible to a unit test on either. Round two adds the Exn twin of the silent skip (`handle[Exn]` in a `pure` function — the walk called the shared tag registration and discarded the verdict, so it compiled where the declared-row spelling was a clean E612) and the four CONTRACT positions, which are lowered code: a handler in a `requires`, an `ensures`, an `assert`, or a `decreases` measure. The differential gained a **validation leg** — every HANDLER-BEARING module is handed to `wasmtime.Module` through the exceptions-enabled engine `execute()` uses, because a symbol declared at the WRONG TYPE passes the name comparison while being invalid WASM, and 10 of the 30 handler-bearing modules fail to load with `wasm_exceptions` off — a supported wasmtime configuration, though the current runner defaults it on. The conformance suite's deliberate negatives are filtered out of the sweep: they never reach codegen through `vera check`. Carries floors on programs swept, modules validated, summed symbol references and globally distinct symbols, plus three can-go-red tests: the State and Exn extractions each stripped of their declaration lines, and a planted retyped import that only the validation leg catches. Round five adds the three positions no corpus program contained — a destructuring `let`'s value (which also disarmed the E612 gate), a module call's ARGUMENTS, and a signature refinement predicate reached through the alias table — and a cross-module shape test for the module-call leg. Round seven adds the boundary-guard routes that enumeration missed (a tuple parameter's components, a tuple return's, and a closure's refined formal and return) and the co-extensiveness half those shapes cannot show: a refined tuple behind a CLOSURE formal must declare nothing, since the closure path emits no component guards, and the nested-refinement (E618) and erased-base bails must stay silent registrars too. Plus the cycle guard on the closure signature leg, asserted in both directions — the walk terminates, and with the guard neutered the same walk blows the recursion limit | diff --git a/tests/test_exn_throw_payload_1268.py b/tests/test_exn_throw_payload_1268.py index c845c2dc..3a34b6fd 100644 --- a/tests/test_exn_throw_payload_1268.py +++ b/tests/test_exn_throw_payload_1268.py @@ -32,6 +32,8 @@ """ from __future__ import annotations +import re + import pytest from vera.codegen import execute @@ -42,6 +44,7 @@ _run, _run_refine_trap, _run_trap, + wat_fn_body, ) from tests.verifier_helpers import _verify, _verify_err @@ -634,6 +637,111 @@ def test_the_unguarded_disclosure_no_longer_names_the_throw_payload( assert "or an Exn `throw` payload)" not in rationales[0], rationales[0] +class TestTheQualifiedArmObligatesAllThreeArms: + """The qualified arm records the WIDENING too, not just two of three. + + PR #1325 review. The `QualifiedCall` arm was hand-written as a + refined-then-@Nat chain, and simply had no `@Nat` -> `@Int` widening + branch — so `State.put(@Nat.0)` / `Exn.throw(@Nat.0)` into an `@Int` + cell recorded NO obligation at all, while codegen emitted the widening + guard on both spellings (the qualified forms synthesize a bare node and + delegate to the dispatcher that emits it). A guard the obligation + stream never mentions is the same disease as a guard it claims and does + not emit: `verify --json` is the only place a reader can see either. + + Asserted as a differential over the two spellings of each op rather + than as literals, because the two spellings ARE one boundary. + """ + + def _widen(self, op: str, spelling: str) -> str: + """A `@Nat` argument widening into an `@Int` cell / payload.""" + if op == "throw": + return f""" +private fn boom(@Nat -> @Int) + requires(true) + ensures(true) + effects(>) +{{ + {spelling}(@Nat.0) +}} + +public fn main(@Nat -> @Int) + requires(true) + ensures(true) + effects(pure) +{{ + handle[Exn] {{ + throw(@Int) -> {{ @Int.0 }} + }} in {{ + boom(@Nat.0) + }} +}} +""" + return f""" +private fn store(@Nat -> @Unit) + requires(true) + ensures(true) + effects(>) +{{ + {spelling}(@Nat.0) +}} + +public fn main(@Nat -> @Int) + requires(true) + ensures(true) + effects(pure) +{{ + handle[State](@Int = 1) {{ + get(@Unit) -> {{ resume(@Int.0) }}, + put(@Int) -> {{ resume(()) }} + }} in {{ + store(@Nat.0); + 0 + }} +}} +""" + + @pytest.mark.parametrize( + ("op", "bare", "qualified"), + [("throw", "throw", "Exn.throw"), ("put", "put", "State.put")], + ) + def test_both_spellings_record_the_widening( + self, op: str, bare: str, qualified: str, + ) -> None: + """One `nat_to_int_coerce` on each side, at the same status.""" + def coerce(src: str) -> list[tuple[str, str]]: + return [(o.kind, o.status) for o in _verify(src).obligations + if o.kind == "nat_to_int_coerce"] + + b = coerce(self._widen(op, bare)) + q = coerce(self._widen(op, qualified)) + assert b == q, (b, q) + assert b == [("nat_to_int_coerce", "tier3")], b + + @pytest.mark.parametrize( + ("op", "spelling"), + [("throw", "throw"), ("throw", "Exn.throw"), + ("put", "put"), ("put", "State.put")], + ) + def test_the_guard_the_obligation_promises_is_emitted( + self, op: str, spelling: str, + ) -> None: + """...and codegen really does emit it, on BOTH spellings. + + The obligation above says `tier3` — runtime-guarded — so this is the + half that makes that a fact rather than a claim. Without it the + differential is satisfied by two sides agreeing on a promise neither + keeps. + """ + result = _compile_ok(self._widen(op, spelling)) + body = wat_fn_body(result.wat, "boom" if op == "throw" else "store") + guard = re.compile( + r"local\.tee \d+\s+i64\.const 0\s+i64\.lt_s\s+if\s+unreachable\s+end", + re.S, + ) + assert guard.search(body), body + + class TestAProvedContractSurvivesTheThrowPayload: """The soundness differential: verify says PROVED, so run must agree. diff --git a/vera/verifier.py b/vera/verifier.py index 2af6f841..fe5609e9 100644 --- a/vera/verifier.py +++ b/vera/verifier.py @@ -5081,33 +5081,38 @@ def _walk_for_nat_binding_obligations( op_guarded = op_effect in ("State", "Exn") op_site = ("State-op argument" if op_effect == "State" else "effect-operation argument") - # A concretely-@Nat formal obligates directly (#552); a - # generic (TypeVar) formal — `E.wait` instantiated as + # The SHARED triple, not a local copy of two of its three + # arms. A concretely-@Nat formal obligates directly (#552); + # a generic (TypeVar) formal — `E.wait` instantiated as # `E` — is resolved via the checker's recorded # instantiated target (`_nat_binding_target`, #747), as for - # generic constructor fields. + # generic constructor fields; and a refined formal obligates + # its predicate refined-FIRST (#746), the side-table + # recovering a generic formal instantiated to a RefinedType. + # + # The @Nat -> @Int WIDENING arm is the reason this is a + # delegation rather than an inline chain (PR #1325 review). + # The hand-written version carried refined + nat and simply + # omitted widen, so `State.put(@Nat.0)` / `Exn.throw(@Nat.0)` + # into an `@Int` cell recorded NO obligation at all while + # codegen emitted the widening guard on both spellings (the + # qualified forms synthesize a bare node and delegate to the + # dispatcher that emits it) — a guard the obligation stream + # never mentioned, which is the same obligation-versus-guard + # gap one boundary over that this issue's fix round closed. + # Routing through `_obligate_binding_triple` means the three + # arms cannot drift apart again by omission. for arg, formal in zip(expr.args, param_types): - # #746: a refined effect-op formal obligates the argument - # against its predicate (refined-first); the side-table also - # recovers a generic formal instantiated to a RefinedType. - refined_target = self._refined_binding_target(arg, formal) - if (refined_target is not None - and self._narrows_into_refined(arg, refined_target)): - self._check_refined_binding_obligation( - decl, arg, refined_target, smt, slot_env, - assumptions, site=op_site, - # Only the `throw` payload boundary lowers a - # refinement predicate (#1268); the State write - # boundaries emit sign guards alone, so their - # refined arm stays honestly unguarded. - guarded=op_effect == "Exn", - ) - elif (self._nat_binding_target(arg, formal) - and self._narrows_into_nat(arg)): - self._check_nat_binding_obligation( - decl, arg, smt, slot_env, assumptions, - site=op_site, guarded=op_guarded, - ) + self._obligate_binding_triple( + decl, arg, formal, smt, slot_env, assumptions, + site=op_site, + nat_guarded=op_guarded, widen_guarded=op_guarded, + # Only the `throw` payload boundary lowers a + # refinement predicate (#1268); the State write + # boundaries emit sign guards alone, so their + # refined arm stays honestly unguarded. + refined_guarded=op_effect == "Exn", + ) for arg in expr.args: self._walk_for_nat_binding_obligations( decl, arg, smt, slot_env, assumptions, From 189a25339eae991e8776635f72bb397e0cedc83d Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 19:30:17 +0100 Subject: [PATCH 48/69] A refinement over @Int no longer disables the widening guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review's fourth finding is refuted as stated and real underneath it. Its claim was a verifier-versus-codegen desync: that the elif chain in `_obligate_binding_triple` records only `refine_bind` for a refined-over- `@Int` formal fed an intrinsically-`@Nat` value, while the emitted payload path carries both the refinement guard and the widening guard. The second half is false — measured on `Exn<{ @Int | true }>` fed a `@Nat`, codegen emits the refinement guard and NO sign guard, because the payload lowering mirrors the verifier's chain deliberately: the sign arms are gated on the refined arm not having fired. Obligation and guard agree, one for one. What the measurement did find is worse in one respect: both sides agreed to skip a check the UNREFINED spelling performs. A refinement predicate does not imply fit-in-i64 — `{ @Int | true }` is satisfied by the negative a `@Nat` above i64.MAX reinterprets to, exactly as #820 says `< 100` is — so adding a refinement WEAKENED the boundary. `Exn` fed u64.MAX trapped on the widening guard; `Exn<{ @Int | true }>` fed the same value returned -1. The #820 intersection now holds at these boundaries too: the widening obligation rides alongside the refined one in the shared triple rather than being skipped by the chain, and codegen emits the widening guard beside the predicate guard. Not a double-record — the arms below stay `elif`, so a value reaching the refined branch reaches neither of them, and the two obligations are different kinds describing different facts about one value. The State siblings inherit it through the same helper, where the widening guard was already being emitted, so their disclosure stops understating what the module does. Both spellings now trap at u64.MAX and both deliver an in-range value untouched. Co-Authored-By: Claude --- CHANGELOG.md | 2 +- FAQ.md | 2 +- README.md | 2 +- ROADMAP.md | 2 +- TESTING.md | 4 +- docs/llms-full.txt | 2 +- tests/test_exn_throw_payload_1268.py | 93 ++++++++++++++++++++++++++++ vera/README.md | 2 +- vera/verifier.py | 19 ++++++ vera/wasm/calls.py | 11 ++++ 10 files changed, 131 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39848d47..cad0aa12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - **Two imports supplying one bare name are refused, in every namespace** ([#1304](https://github.com/aallan/vera/issues/1304)). Spec §8.5 ordered a local declaration against an import (§8.5.2) and gave the module-qualified form for reaching what a clash hides (§8.5.3), but defined no order between two *imports* that both supply one name. Neither did the implementation, and the gap was reachable: a module importing two dependencies that each export `forall fn gen` — one returning `@Int`, one `@Bool` — bound its bare call to whichever supplier a set of module paths happened to yield first, so one unchanged file was `vera check`-green on one run and `[E121] body has type Bool` on the next. Measured at the branch point across eight consecutive runs and eight hash seeds: accepted on seeds 0, 2 and 3, rejected on 1, 4, 5, 6 and 7, with the winner tracking module-name hash order rather than which import is written first. Codegen's E608 rail caught the *entry-visible* pair before it could matter there; the flap lived in the shapes the rail only reached at compile, from inside a module the entry program merely imports. Spec §8.5.2.2 now states the rule — a program **MUST NOT** leave a namespace with two imports supplying one bare function name — and the checker enforces it as **E155**, a check-phase code for a scope question that had been enforced by a codegen rail at the wrong layer. Refusing is what removes the flap rather than merely labelling it: with no pick to make, there is no iteration order left to expose, which a deterministic first-wins order would not have achieved (it would make the resolved declaration implicit in import sequence, §0.2.2, and let a library *adding* an export silently rebind a downstream bare call). The refusal is **definition-gated**, matching the rail it generalises: it fires because the import pair exists, not because a body names it, so an entry program importing two suppliers and never calling either is refused exactly as E608 already refused it, and rewriting a bare call in module-qualified form does not lift it. Two shapes clear it, both exercised through to their runtime value: a **local declaration** of the name (§8.5.2 — every bare call is then the local one, and each import stays reachable through `dep::name(...)`), or a **selective import** narrowing the other module's list. The ambiguity predicate is the one `namespace_fn_names` already derived for #1281 and #1299, now exposed per namespace as well as unioned, so the layer that refuses early and the layer that backstops it cannot disagree about which shape is ambiguous; the E608 condition keeps its cell, driven through a door that bypasses the checker. An ambiguous name is not injected into the type environment at all — reporting the clash while binding one supplier would leave the follow-on diagnostics keyed to whichever module the injection loop reached first, which is the nondeterminism the refusal exists to remove — so a bare call to it misses with an ordinary `E200` instead — an E-coded diagnostic emitted at **warning** severity, which the `--json` envelope reports in `warnings` rather than `diagnostics` and which does not fail the check on its own (measured: a program whose only diagnostic is `E200` reports `ok: true` and exits 0). The W-series is the separate `W001`/`W002` code namespace, and this is not one of them. **The data namespaces flapped the same way and are folded in.** Spec §8.5.4 gives constructor names the same shadowing rules as function names, which a function-only refusal would have made false: two modules each exporting a `public data Shape` with different constructor field types type-checked on some hash seeds and reported `[E213]` on others (accepted on seeds 2, 8, 9, 10 and 11; rejected on 0, 1, 3, 4, 5, 6 and 7), and the accepting seeds were the worse half — `check` **and** `verify` both passed, and the program died at `run` with an `E609` located at line 0 of the entry file, naming two modules the entry never imported. Data types are now **E156** and constructors **E157**, one code per declaration namespace exactly as codegen splits E608/E609/E610, and reported independently because they come apart: two modules exporting differently-named types that share a constructor name clash on the constructor alone. Their remedy differs from the function one and says so — E609/E610 refuse two modules' same-named data declarations by DECLARATION, consulting neither visibility nor the importer's filter nor local shadowing (the relaxation E608 received in [#1281](https://github.com/aallan/vera/issues/1281) has no data-side twin), so narrowing an import or shadowing the name locally leaves the program `E609` at compile. Both were measured against the fixture and both fail — as does marking one declaration `private` — so the two diagnostics prescribe renaming, and a cell pins that measurement so the fix text cannot drift into offering remedies that do not work. That rail over-breadth is now tracked as [#1317](https://github.com/aallan/vera/issues/1317). **A name the built-in registry already owns is not a clash** — the injection loops are `setdefault` over a `TypeEnv` the built-ins populate first, so a dependency exporting its own `option_map` never wins the bare name (measured as `E201` against the *prelude's* two-argument signature). The first cut of this refusal did not pass the built-in snapshot and reported two such dependencies as a clash, which was a new rejection rather than an earlier one; `namespace_fn_names`' claim that its ambiguity half is identical with or without the prelude argument was wrong for the same reason and is corrected, with the codegen call ordering it depends on now pinned by a cell. -- **A `throw` payload is runtime-guarded, not only obligated** ([#1268](https://github.com/aallan/vera/issues/1268)). `throw(v)` narrows `v` into the `Exn` payload, and since the static half of this issue the narrowing carries the same obligation every other binding site does — but codegen emitted no guard, so the obligation's Tier-3 leg promised a runtime check that did not exist and an unverified `vera compile`/`run` delivered the violating value anyway. `throw(0 - 5)` under `effects(>)` ran to completion and returned **-5** through the `@Nat` payload; the refined spelling (`type Pos = { @Int | @Int.0 > 0 }`) did the same. Worse than a wrong answer: a handler clause binds the payload at its declared type, so the verifier hands every downstream consumer the invariant the payload just broke — a `@Nat`-taking function discharging `ensures(@Bool.result)` at Tier 1 from its parameter's type alone reported a **postcondition violation at run time on a postcondition `vera verify` had proved**. `throw` now takes the write boundary's guards at its op-call site, beside `put`'s ([#1203](https://github.com/aallan/vera/issues/1203)): the `@Int` -> `@Nat` sign guard, the `@Nat` -> `@Int` widening guard, and — refined FIRST, as at every other narrowing site — the §2.6.5 predicate guard for a refined payload, which traps through `$vera.contract_fail` naming the predicate that failed (`Refinement violation in throw(@Pos) / payload: @Int.0 > 0 failed`). The three arms mirror the verifier's own obligation triple one-for-one, so the obligation stream and the emitted guards stay in lock-step: the payload obligation is now `guarded` at all three arms and its Tier-3 leg is counted in `tier3_runtime` rather than disclosed as `tier3_unguarded`, and the refined arm's `guarded` claim is intersected with the same `_refined_boundary_codegen_guardable` test every other refined site uses, so an erased `@Unit` base or a nested refinement — which codegen emits no guard for — stays honestly unguarded. That mirror needed one repair to be true: it answered "guarded" for a refinement OVER a refinement, which `_refinement_guard_parts` refuses outright with a loud `E618` because the outer predicate alone would silently drop the inner membership — so `vera verify` exited 0 recording a Tier-3 runtime check for a program `vera compile` then refuses, a promise about a run that can never happen. It now bails on a refinement base, and the obligation discloses `tier3_unguarded` while `E618` still refuses. The same audit found the **qualified spelling recording something different from the bare one**: the `QualifiedCall` arm hardcoded `guarded=False` behind a comment stale since [#1203](https://github.com/aallan/vera/issues/1203), so `Exn.throw(v)` — which codegen lowers by synthesizing a bare node and delegating to the very dispatcher that emits the guards — disclosed `E504`/`E506` for a boundary that traps, and `State.put(v)` had been doing the same since #1203. Both now take the bare arm's rule on the same key (`op.parent_effect`), so the two spellings of one operation record identical statuses. The review of that fix found the arm had been hand-written as a refined-then-`@Nat` chain with **no widening branch at all**, so `State.put(@Nat.0)` / `Exn.throw(@Nat.0)` into an `@Int` cell recorded no obligation whatever while codegen emitted the `@Nat` -> `@Int` widening guard on both spellings — a guard the obligation stream never mentioned, the mirror image of the claim-without-a-guard this issue started from. It now routes through the shared `_obligate_binding_triple`, so the three arms cannot drift apart again by omission; a user-declared effect's operation and `IO.sleep`'s `@Nat` formal stay the honest [#754](https://github.com/aallan/vera/issues/754) unguarded class. Two diagnostic rationales (`E504`, `E531`) that listed the `throw` payload among the unguarded sites — false once the guard landed, and contradicting the spec sentences this change amends — no longer do. Reaching the predicate needed the payload's TYPE, which neither of a cell's two names carries: `family` renders the predicate and `base` strips it, so `CellNames` now carries the type expression its producer already held rather than parsing one back out of a mangled family name. The predicate lowering itself is injected into the translation context (`set_refinement_guard_emitter`), because the two halves of a §2.6.5 guard sit on opposite sides of that seam — which local at what width is the context's question, while the trap message, the contract-fail import and the E617/E618 diagnostics are the generator's. An unrefined payload's WAT is byte-identical to before: a differential over all 278 pre-existing corpus programs — every `examples/` and `tests/conformance/` program, compiled and verified on both trees — moves nothing, in emitted WAT or in the obligation and diagnostic streams. +- **A `throw` payload is runtime-guarded, not only obligated** ([#1268](https://github.com/aallan/vera/issues/1268)). `throw(v)` narrows `v` into the `Exn` payload, and since the static half of this issue the narrowing carries the same obligation every other binding site does — but codegen emitted no guard, so the obligation's Tier-3 leg promised a runtime check that did not exist and an unverified `vera compile`/`run` delivered the violating value anyway. `throw(0 - 5)` under `effects(>)` ran to completion and returned **-5** through the `@Nat` payload; the refined spelling (`type Pos = { @Int | @Int.0 > 0 }`) did the same. Worse than a wrong answer: a handler clause binds the payload at its declared type, so the verifier hands every downstream consumer the invariant the payload just broke — a `@Nat`-taking function discharging `ensures(@Bool.result)` at Tier 1 from its parameter's type alone reported a **postcondition violation at run time on a postcondition `vera verify` had proved**. `throw` now takes the write boundary's guards at its op-call site, beside `put`'s ([#1203](https://github.com/aallan/vera/issues/1203)): the `@Int` -> `@Nat` sign guard, the `@Nat` -> `@Int` widening guard, and — refined FIRST, as at every other narrowing site — the §2.6.5 predicate guard for a refined payload, which traps through `$vera.contract_fail` naming the predicate that failed (`Refinement violation in throw(@Pos) / payload: @Int.0 > 0 failed`). The three arms mirror the verifier's own obligation triple one-for-one, so the obligation stream and the emitted guards stay in lock-step: the payload obligation is now `guarded` at all three arms and its Tier-3 leg is counted in `tier3_runtime` rather than disclosed as `tier3_unguarded`, and the refined arm's `guarded` claim is intersected with the same `_refined_boundary_codegen_guardable` test every other refined site uses, so an erased `@Unit` base or a nested refinement — which codegen emits no guard for — stays honestly unguarded. That mirror needed one repair to be true: it answered "guarded" for a refinement OVER a refinement, which `_refinement_guard_parts` refuses outright with a loud `E618` because the outer predicate alone would silently drop the inner membership — so `vera verify` exited 0 recording a Tier-3 runtime check for a program `vera compile` then refuses, a promise about a run that can never happen. It now bails on a refinement base, and the obligation discloses `tier3_unguarded` while `E618` still refuses. The same audit found the **qualified spelling recording something different from the bare one**: the `QualifiedCall` arm hardcoded `guarded=False` behind a comment stale since [#1203](https://github.com/aallan/vera/issues/1203), so `Exn.throw(v)` — which codegen lowers by synthesizing a bare node and delegating to the very dispatcher that emits the guards — disclosed `E504`/`E506` for a boundary that traps, and `State.put(v)` had been doing the same since #1203. Both now take the bare arm's rule on the same key (`op.parent_effect`), so the two spellings of one operation record identical statuses. The review of that fix found the arm had been hand-written as a refined-then-`@Nat` chain with **no widening branch at all**, so `State.put(@Nat.0)` / `Exn.throw(@Nat.0)` into an `@Int` cell recorded no obligation whatever while codegen emitted the `@Nat` -> `@Int` widening guard on both spellings — a guard the obligation stream never mentioned, the mirror image of the claim-without-a-guard this issue started from. It now routes through the shared `_obligate_binding_triple`, so the three arms cannot drift apart again by omission. The triple itself then turned out to be missing the [#820](https://github.com/aallan/vera/issues/820) INTERSECTION at these boundaries: its three arms are an `elif` chain, so a refinement OVER `@Int` claimed the value and the widening check never ran — and codegen mirrored that exactly, so both sides agreed to skip a check the UNREFINED spelling performs. A refinement predicate does not imply fit-in-i64, and `{ @Int | true }` is satisfied by the negative a `@Nat` above i64.MAX reinterprets to, so adding a refinement WEAKENED the boundary: `Exn` fed u64.MAX trapped on the widening guard while `Exn<{ @Int | true }>` fed the same value returned **-1**. The widening obligation and its guard now ride beside the refined pair rather than being replaced by it, and the two spellings trap alike; a user-declared effect's operation and `IO.sleep`'s `@Nat` formal stay the honest [#754](https://github.com/aallan/vera/issues/754) unguarded class. Two diagnostic rationales (`E504`, `E531`) that listed the `throw` payload among the unguarded sites — false once the guard landed, and contradicting the spec sentences this change amends — no longer do. Reaching the predicate needed the payload's TYPE, which neither of a cell's two names carries: `family` renders the predicate and `base` strips it, so `CellNames` now carries the type expression its producer already held rather than parsing one back out of a mangled family name. The predicate lowering itself is injected into the translation context (`set_refinement_guard_emitter`), because the two halves of a §2.6.5 guard sit on opposite sides of that seam — which local at what width is the context's question, while the trap message, the contract-fail import and the E617/E618 diagnostics are the generator's. An unrefined payload's WAT is byte-identical to before: a differential over all 278 pre-existing corpus programs — every `examples/` and `tests/conformance/` program, compiled and verified on both trees — moves nothing, in emitted WAT or in the obligation and diagnostic streams. - **`json_parse` accepts one domain, and both runtimes accept it** ([#1306](https://github.com/aallan/vera/issues/1306)). The reference host parsed with `json.loads`, whose default `parse_constant` admits `NaN`, `Infinity` and `-Infinity`; the browser gated with `JSON.parse`, which refuses them as RFC 8259 requires. So the two hosts disagreed about *which call* rejects a non-finite value: the browser at the parse with a handled `Err`, the reference host at `json_stringify` — and there as a raw Python traceback rather than a Vera error ([#1302](https://github.com/aallan/vera/issues/1302) below). Spec §9.7.1 now states the accepted domain instead of leaving each host to inherit its parser's: RFC 8259-valid text that decodes to finite numbers and strings of Unicode scalar values, everything else `Err` at the parse with the same message on every runtime. **A non-finite number has two entry routes and the domain closes both.** The constants are one; the other is a syntactically valid number that overflows — `1e999`, `-1e999`, `[1e999]`, `{"a":1e309}` — which both host parsers accept, decoding to an infinite `JNumber` that then died at `json_stringify`, the same divergent-refusal-point defect one syntax over. RFC 8259 §6 sets no limit on a number's range and says an implementation may set one; Vera's is the finite `Float64` values, which is exactly what `json_stringify` can write back. **The refusal covers the integer spelling too**, and that half was reference-host-only: `json.loads` returns a Python `int` for a digit string with no fraction and no exponent, so `1` followed by 309 zeros never met a float range check — and then had to become an f64 at the WASM boundary, where `float()` raises. It died with `int too large to convert to float` where `JSON.parse`, which has no int/float split and sees an `Infinity` either way, returned the shared sentence. The integer bound is the double **rounding** boundary (`2**1024 - 2**970`) rather than `sys.float_info.max`, and compared in integer arithmetic: an integer *larger* than the largest finite double still rounds to it and both hosts accept it, so the obvious bound would have traded this divergence for its mirror image, and a bound implemented as `float(value)` would be the very overflow it is looking for. Underflow is not the same question and is not refused: `1e-999` decodes to `0`, finite and in the domain, pinned as a control beside `1e308` and the largest representable double so a refusal cannot generalise from "unrepresentable magnitude" to "large". Both value-level exclusions — overflow and lone surrogate — are found by ONE document-order walk returning the sentence itself, so "whichever comes first names the refusal" is the rule rather than a precedence table the two hosts could implement differently. The domain is the parse-side counterpart of the canonical output form [#1293](https://github.com/aallan/vera/issues/1293) pinned — a non-finite number has no JSON representation in either direction — and with no entry route through `json_parse`, the output-side refusal is now reachable only from a `JNumber` a program *constructed* from `nan()` or `infinity()`. The reference host's `parse_constant` hook **records rather than raises**, and the refusal is decided after the parse completes. Raising on sight would have made it answer a different question from the browser's: Python's scanner calls the hook the moment it sees the token, so `[Infinity_x]` — malformed for a reason that has nothing to do with the constant — would have reported the non-finite sentence natively while the browser reported a syntax error. Recording and continuing asks what the browser asks, by substituting `0` for each bare constant and re-parsing: *would this text be valid JSON if the constants were admitted?* Only then is the constant the whole story, and only then do both hosts say the same sentence; text malformed for any other reason keeps its host-native syntax message, as every syntax error always has. The browser's scan also only considers a token where a *value* may begin — the start of the text, or after `[`, `,` or `:`. Without that it found `NaN` at offset 1 of `-NaN`, substituted, re-parsed `-0` successfully and reported the shared sentence, where the reference host's parser never reaches the token at all and gives a syntax error. `-NaN`, `[-NaN]`, `+Infinity`, `infinity`, `nan`, `NaNx` and `-Infinityx` are all pinned as host-native on both hosts. The parity battery pins all four probe inputs from the issue's table plus the container and multi-constant shapes, compares the whole `Err` message across hosts rather than which arm was taken, and runs beside controls the refusal must not disturb — `"NaN"` as an ordinary string value among them. diff --git a/FAQ.md b/FAQ.md index b4b42e73..3e990049 100644 --- a/FAQ.md +++ b/FAQ.md @@ -279,7 +279,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 11,623 tests, including a 242-program conformance suite +- 11,633 tests, including a 242-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/README.md b/README.md index d65c8137..fdb31928 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ cp /path/to/vera/SKILL.md ~/.claude/skills/vera-language/SKILL.md ## Project status -Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 11,623 tests, 95% Python code coverage, 242 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. +Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 11,633 tests, 95% Python code coverage, 242 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. The reference compiler — parser, AST, type checker, contract verifier (Z3), WASM code generator, module system, browser runtime, and runtime contract insertion — is working. The language specification is in draft across [14 chapters](spec/). diff --git a/ROADMAP.md b/ROADMAP.md index 703d275d..b31aa397 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,7 +12,7 @@ Ordering derives from the design principles ([DESIGN.md](DESIGN.md)): verificati ## Where we are -11,623 tests, 242 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. +11,633 tests, 242 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. ## Stage 19 — The verification completeness sprint diff --git a/TESTING.md b/TESTING.md index 9aff98d5..ccb398fa 100644 --- a/TESTING.md +++ b/TESTING.md @@ -6,7 +6,7 @@ This is the single source of truth for Vera's testing infrastructure, coverage d | Metric | Value | |--------|-------| -| **Tests** | 11,623 across 173 files (~155,000 lines of test code; 11,426 passed + 26 stress, 171 skipped) | +| **Tests** | 11,633 across 173 files (~155,000 lines of test code; 11,436 passed + 26 stress, 171 skipped) | | **Compiler code coverage** | 95% Python, 87% JavaScript (CI minimum: 80%) | | **Conformance programs** | 242 programs across 9 spec chapters, validating every language feature | | **Example programs** | 42, all validated through `vera check` + `vera verify` | @@ -72,7 +72,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_naming_env_provenance_1208.py` | 45 | 2,097 | The other half of the #1208 contract: every consumer is handed the ENVIRONMENT the checker rendered under, not just the same renderer. Four provenance seams, each pinned by the adversarial probe that exhibited it — an IMPORTED callee's contract rendered in its DEFINING module's alias namespace (a violated precondition that vanished, and its mirror, a correct call spuriously rejected, both under a `Cnt` that names different bodies on the two sides), an imported GENERIC monomorphized and verified in that same namespace (a lying postcondition that proved clean, plus a verifier↔codegen clone differential over the recounted slot references — the desync is invisible to a unit test on either side), a `forall` variable shadowing a same-named module alias wherever a generic signature renders (the mono clone, a body `let`, the verifier's collapsed premises, and the exported uninstantiated template), and the tester's `SmtContext` holding the narrowed scope its own names were keyed in — the last one behavioural, since a generator handed the wrong scope collapses two parameters onto one variable and returns NO inputs at all. Three of the seams are also crossed against a SECOND component rather than checked for internal consistency, because a wrong-but-consistent scope is invisible from inside one: the verifier's declared parameter names against `slots.slot_table`'s, its `where`-helper scope against `slots.fn_scopes`' accumulation, and the monomorphizer's post-substitution names against what the consumers rebuild on the clone — each independent on the axis under test (which variables the two sides narrow by) but sharing `fn_slot_scope`/`slot_name` below it, so the hand-derived literal rendering beside every comparison is what a shared-renderer defect cannot satisfy. An imported generic nested under a non-generic function is pinned too — both its discovery-time recount and its verification-time clone must run in the DEFINING module's namespace. Three more seams arrived from the PR #1224 review, each with the false Tier-1 or miscompile that exhibited it: an UNPINNED callee (an imported generic's own `where`-helper, which the origin registry never pins) rendering in the module under verification rather than the entry program, whose absence let a violated precondition discharge as true and trap at run time; a callee's refined-RETURN predicate translated in the callee's namespace alongside its `requires`/`ensures`, pinned by provenance because today's bare-headed binder masks it behaviourally; and codegen's declaration-index space keyed PER NAMESPACE, without which a module's stamp turned the main file's forward alias reference into a backward one and a check-clean, verify-clean program read the wrong parameter through valid WASM. Two seams from the #1213 burndown close the same shape from the other side: the prelude's own aliases are injected only into the reserved `Vera` namespace, so no name a program can spell resolves on the codegen side alone (#1221 — the differential compares the checker's and codegen's partition of one signature, with the emitted WAT beside it), and an imported ADT is ordered at the index its OWN module gave it rather than the built-in floor (#1227), each with the control that differs by exactly the namespace under test. Controls carried alongside: the same programs with the alias renamed or the shadowing declaration removed, and the runtime oracle that shows the new E500/E501 agrees with the emitted code rather than merely reporting more | | `test_callee_contract_scope_1220_1225_1226.py` | 40 | 1,433 | A callee's contract is READ in the callee's own module. Three burndown defects, each asserted against the runtime oracle wherever the two directions of a wrong namespace (an obligation that vanishes, one that fires for no reason) look identical from inside the verifier: an E501's `Precondition:` line quoted from the file that DECLARED the clause (#1220 — in the misattribution direction too, both files carrying a plausible `requires` on the same line number, plus the imported-generic `where`-helper whose clause sits past the end of a short importer and used to quote nothing at all); a bare-name call inside an IMPORTED callee's contract resolved through the CALLEE's module registry (#1225 — the false Tier-1 whose run traps and its mirror spurious E501, through the `requires` and the `ensures` path, against a no-collision control); and the refined-RETURN binder derived through the naming layer, so a refinement over a PARAMETERISED base pushes the key its own predicate looks itself up under (#1226 — single-module and cross-module, the second proving the derivation happens INSIDE the callee scope); plus the PR #1239 review round — a module's pinned registry holds what its OWN file imports (a DEPTH-2 chain, the shape the single-level corpus could not exhibit: requires and ensures directions, bare-vs-qualified tier agreement, a name outside the middle module's import filter still missing per §8.5.1, and the mirror gate that filling the registry does not re-export), and every part of a diagnostic follows the declaring module (location, file name and excerpt, including a clause past a shorter importer's end, with a multi-line clause quoted whole), and the characterization of the binder-reference walk's one documented exception — a CLOSURE inside a predicate owns the first `@T.n` in traversal order, whose consequence is a Tier-3 demotion rather than a fact assumed about the wrong term; and the mini-review round — an obligation carries the FILE its line number belongs to, so the documented `(file, line, column)` join between the two `--json` arrays holds for a module-located obligation, with the entry-file control and warm==cold parity, and a multi-line clause is quoted with its `--` comments blanked (a `--` inside a string literal surviving, which a naive split would corrupt) | | `test_alias_application_refinement_base_1237.py` | 11 | 398 | A parameterised alias APPLICATION substitutes its arguments in the verifier's own resolver (#1237). `type Box = T;` applied as `@Box` resolved to `AdtType('T')` — the alias's binder leaking as an ADT name — so a refinement over it failed the modelled-primitive gate, the refined-return fact was dropped, and a valid program was rejected with a spurious E501 while `vera run` returned the right answer. Both halves of the fix are asserted separately: the alias body registers its own parameters as type variables (`substitute` maps type variables, so an ADT-named binder is unsubstitutable however the application side is written) and the application substitutes. Plus depth (an argument that is itself an application, and an alias whose body applies another alias), the bounded direction (a consumer wanting `>= 100` where the refinement grants `>= 18` is still rejected, and the runtime agrees), and the gate that must NOT move — an unmodelled `@Byte` base resolves correctly and still degrades to a Tier-3 runtime check, with a consumer of its predicate still refused | -| `test_exn_throw_payload_1268.py` | 38 | 956 | `throw`'s payload is obligated AND runtime-guarded like every other narrowing site (#1268). `throw(0 - 5)` under `effects(>)` verified at 4/4 Tier 1 with ZERO obligations while `vera run` returned -5 out of the `@Nat` payload — check-green, verify-green, silently wrong — because `throw` is a bare call with no function-registry entry, so the argument loop never saw it and the table-driven fallback added for the same hole at the State `put` was keyed on that one name. All three arms are asserted (the `@Nat` refutation, the refined refutation over a modelled base, and the `@Nat`->`@Int` widening obligation appearing where none existed), plus the #1251(b) concrete gate reaching the payload for free (`throw(200)` into an `Exn<{ @Byte \| @Byte.0 < 10 }>` names the value; the satisfying twin proves at Tier 1), the user-effect contrast that localized the bug (a declared op's argument was loud for the same value — both are loud now, at the same site name), and the refined-alias payload spelling. The GUARD is checked against codegen rather than asserted: an undischargeable payload must land on the runtime-guarded `tier3` leg, counted in the totals, and a run must confirm the payload really is stopped — the two together go red whichever side moves without the other (delete the emission and the runs go red; flip the flag back and the statuses do). Both arms are run at the boundary: a `@Nat` payload traps on -5 and delivers 5, a refined `{ @Int \| @Int.0 > 0 }` payload traps through the `$vera.contract_fail` channel on BOTH -5 and 0 — the value that clears the base's `>= 0` and violates the predicate, so a sign guard standing in for the predicate guard fails here — and delivers 7. The type gate carries its own over-refusal control: an `Exn` payload has no invariant to violate, so a negative one is a correct program and must still return -5. Both REPRESENTATIONS are covered — a scalar payload in one local, and a `@String`-based one whose (ptr, len) pair has to be saved in two, checked over the ptr and put back in the right order (the satisfying twin is what shows the order). The **soundness differential** is the point of the guard rather than a property of it: the clause parameter's type is what the verifier hands every downstream consumer, so a consumer discharging `ensures(@Bool.result)` at Tier 1 from its `@Nat` parameter alone is asserted PROVED and then run — pre-fix the run reported a postcondition violation on that proved postcondition, with nothing else in the path (the argument is already `@Nat`-typed, so no call-site narrowing guard fires). A dischargeable twin proves the site is not merely always-loud, and the six `Exn` conformance programs are verified whole as canaries so obligating a position that had none names itself instead of arriving as one line of a corpus sweep. The adversarial round adds the three places the `guarded` PROMISE and the emitted guard could disagree in a direction no value oracle can see, because the program either never runs or runs identically either way: a refinement OVER a refinement is asserted `tier3_unguarded` AND `E618`-refused at compile in one cell (the mirror claimed a runtime check for a program that cannot be compiled at all — either half alone reads as consistent); the bare and qualified spellings of one operation are asserted to record IDENTICAL statuses as a differential rather than two literals, on both arms, with the run confirming which value is the true one (`Exn.throw` was disclosed unguarded while codegen delegated it to the guard-emitting dispatcher, and `State.put` had been since #1203); and `E504`'s rationale is read from a real diagnostic — reached through the site that IS still unguarded, a user-declared effect's operation argument — to pin that it no longer lists the `throw` payload among the sites with no runtime guard | +| `test_exn_throw_payload_1268.py` | 42 | 1049 | `throw`'s payload is obligated AND runtime-guarded like every other narrowing site (#1268). `throw(0 - 5)` under `effects(>)` verified at 4/4 Tier 1 with ZERO obligations while `vera run` returned -5 out of the `@Nat` payload — check-green, verify-green, silently wrong — because `throw` is a bare call with no function-registry entry, so the argument loop never saw it and the table-driven fallback added for the same hole at the State `put` was keyed on that one name. All three arms are asserted (the `@Nat` refutation, the refined refutation over a modelled base, and the `@Nat`->`@Int` widening obligation appearing where none existed), plus the #1251(b) concrete gate reaching the payload for free (`throw(200)` into an `Exn<{ @Byte \| @Byte.0 < 10 }>` names the value; the satisfying twin proves at Tier 1), the user-effect contrast that localized the bug (a declared op's argument was loud for the same value — both are loud now, at the same site name), and the refined-alias payload spelling. The GUARD is checked against codegen rather than asserted: an undischargeable payload must land on the runtime-guarded `tier3` leg, counted in the totals, and a run must confirm the payload really is stopped — the two together go red whichever side moves without the other (delete the emission and the runs go red; flip the flag back and the statuses do). Both arms are run at the boundary: a `@Nat` payload traps on -5 and delivers 5, a refined `{ @Int \| @Int.0 > 0 }` payload traps through the `$vera.contract_fail` channel on BOTH -5 and 0 — the value that clears the base's `>= 0` and violates the predicate, so a sign guard standing in for the predicate guard fails here — and delivers 7. The type gate carries its own over-refusal control: an `Exn` payload has no invariant to violate, so a negative one is a correct program and must still return -5. Both REPRESENTATIONS are covered — a scalar payload in one local, and a `@String`-based one whose (ptr, len) pair has to be saved in two, checked over the ptr and put back in the right order (the satisfying twin is what shows the order). The **soundness differential** is the point of the guard rather than a property of it: the clause parameter's type is what the verifier hands every downstream consumer, so a consumer discharging `ensures(@Bool.result)` at Tier 1 from its `@Nat` parameter alone is asserted PROVED and then run — pre-fix the run reported a postcondition violation on that proved postcondition, with nothing else in the path (the argument is already `@Nat`-typed, so no call-site narrowing guard fires). A dischargeable twin proves the site is not merely always-loud, and the six `Exn` conformance programs are verified whole as canaries so obligating a position that had none names itself instead of arriving as one line of a corpus sweep. The adversarial round adds the three places the `guarded` PROMISE and the emitted guard could disagree in a direction no value oracle can see, because the program either never runs or runs identically either way: a refinement OVER a refinement is asserted `tier3_unguarded` AND `E618`-refused at compile in one cell (the mirror claimed a runtime check for a program that cannot be compiled at all — either half alone reads as consistent); the bare and qualified spellings of one operation are asserted to record IDENTICAL statuses as a differential rather than two literals, on both arms, with the run confirming which value is the true one (`Exn.throw` was disclosed unguarded while codegen delegated it to the guard-emitting dispatcher, and `State.put` had been since #1203); the #820 INTERSECTION at this boundary — a refinement over `@Int` keeps the widening obligation AND its guard beside the predicate's, pinned as a differential against the unrefined spelling (both must trap at u64.MAX, where the refined one used to return -1) with an in-range control so the guard is not simply always-on; and `E504`'s rationale is read from a real diagnostic — reached through the site that IS still unguarded, a user-declared effect's operation argument — to pin that it no longer lists the `throw` payload among the sites with no runtime guard | | `test_refinement_binder_convergence_1208.py` | 16 | 459 | Codegen's refinement boundary guard and `vera/naming.py` derive the predicate binder ONCE (#1208). A per-shape differential over a direct refinement, an alias hop, `@Nat`- and `@Byte`-based refinements (both range-conjoining), a composite base whose binder is a RESOLVED argument list, and two non-refinements that must be `None` on both sides — plus the property the convergence exists to hold, that the guard's binder equals the key a predicate's own `@Base.n` resolves to. A per-shape differential is green either way while two copies agree — which is exactly how a duplicated derivation drifts unnoticed — so the load-bearing assertion is a MUTATION: perturb `naming.refinement_binder_parts` and codegen's guard must report the perturbed binder. Codegen's two layered WASM decisions are pinned alongside — the loud E618 for a nested refinement base, reported ONCE per declaration however many call sites consult the derivation and however many clones a generic is instantiated into (two genuinely distinct sites still report twice), and no guard at all for an erased one (parametrized over `@Unit` and `Future`, the corner that erases identically but is not spelled `Unit`) — plus runtime traps proving BOTH the `@Byte` and the `@Nat` range conjunctions reach the emitted check, each pinned by trap `kind` as well as by the conjunct its message names. The mutation perturbs the predicate as well as the binder name: the two travel by different routes, and the range conjunction lives on the predicate. The once-per-site dedup is keyed on a resolved location, so a cross-module pin holds up the premise that a location carries its owning file: two imported library modules of identical shape, declaring their nested refinement at the same line and column, must produce two diagnostics attributed to two files — each quoting its own module's declaration, which is also what catches an attribution pointing past the importer's last line | | `test_checker_effects.py` | 90 | 1,455 | Effect declarations, abilities, effect subtyping, async effect, handler typing (#420 split), and the #1149 built-in-effect redeclaration gate (E152: divergent and faithful `effect IO`, codegen-only `Exn`, a registry-parametrised sweep, and a differential pinning the gate's name set to what `vera effects --json` publishes) | | `test_state_exn_registration.py` | 30 | 1296 | #1210 — State/Exn host-import registration covers the whole handler, not just its body. Four shapes, one per sub-expression position the walk used to miss (a nested handler in a clause body, in the state-init expression, in a clause's `with` update, and an `Exn` handler in a clause body), each check-green and verify-clean and therefore required to COMPILE — pre-fix every one died at whole-module WAT compilation with `unknown func` / `unknown tag`; plus the `i32_pair` cell (`handle[State]` in a `pure` function) that the walk skipped in silence, now the same loud E607 the declared-effect gate emits. The **registration-completeness differential** is the cross-component invariant itself: over every `examples/` + `tests/conformance/` program that compiles, every `state_*` / `exn_*` symbol the emitted WAT REFERENCES must have a matching import or tag DECLARATION — a desync between the registration pass and the lowering pass is invisible to a unit test on either. Round two adds the Exn twin of the silent skip (`handle[Exn]` in a `pure` function — the walk called the shared tag registration and discarded the verdict, so it compiled where the declared-row spelling was a clean E612) and the four CONTRACT positions, which are lowered code: a handler in a `requires`, an `ensures`, an `assert`, or a `decreases` measure. The differential gained a **validation leg** — every HANDLER-BEARING module is handed to `wasmtime.Module` through the exceptions-enabled engine `execute()` uses, because a symbol declared at the WRONG TYPE passes the name comparison while being invalid WASM, and 10 of the 30 handler-bearing modules fail to load with `wasm_exceptions` off — a supported wasmtime configuration, though the current runner defaults it on. The conformance suite's deliberate negatives are filtered out of the sweep: they never reach codegen through `vera check`. Carries floors on programs swept, modules validated, summed symbol references and globally distinct symbols, plus three can-go-red tests: the State and Exn extractions each stripped of their declaration lines, and a planted retyped import that only the validation leg catches. Round five adds the three positions no corpus program contained — a destructuring `let`'s value (which also disarmed the E612 gate), a module call's ARGUMENTS, and a signature refinement predicate reached through the alias table — and a cross-module shape test for the module-call leg. Round seven adds the boundary-guard routes that enumeration missed (a tuple parameter's components, a tuple return's, and a closure's refined formal and return) and the co-extensiveness half those shapes cannot show: a refined tuple behind a CLOSURE formal must declare nothing, since the closure path emits no component guards, and the nested-refinement (E618) and erased-base bails must stay silent registrars too. Plus the cycle guard on the closure signature leg, asserted in both directions — the walk terminates, and with the guard neutered the same walk blows the recursion limit | diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 2c6b265c..a440350b 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -3265,7 +3265,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 11,623 tests, including a 242-program conformance suite +- 11,633 tests, including a 242-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/tests/test_exn_throw_payload_1268.py b/tests/test_exn_throw_payload_1268.py index 3a34b6fd..657a5430 100644 --- a/tests/test_exn_throw_payload_1268.py +++ b/tests/test_exn_throw_payload_1268.py @@ -742,6 +742,99 @@ def test_the_guard_the_obligation_promises_is_emitted( assert guard.search(body), body +class TestARefinementDoesNotDisableTheWideningGuard: + """A refinement OVER `@Int` rides BESIDE the widening obligation. + + The #820 intersection, which the shared triple was missing at this + boundary (PR #1325 review). The three arms are an `elif` chain, so a + refined formal claimed the value and the widening check never ran — and + codegen mirrored it exactly, which is why this was not a + verifier-versus-codegen desync but something worse in one respect: both + sides agreed to skip a check the UNREFINED spelling performs. + + A refinement predicate does not imply fit-in-i64. `@Nat` is u64 and + `@Int` is i64, so a `@Nat` above i64.MAX reinterprets to a negative + `@Int` — and `{ @Int | true }` is satisfied by that negative, as `< 100` + would be. Adding a refinement therefore WEAKENED the boundary: measured + before the fix, `Exn` fed u64.MAX trapped on the widening guard + while `Exn<{ @Int | true }>` fed the same value returned -1. + """ + + _REFINED = """ +type AnyInt = { @Int | true }; + +private fn boom(@Nat -> @Int) + requires(true) + ensures(true) + effects(>) +{ + throw(@Nat.0) +} + +public fn main(@Nat -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + handle[Exn] { + throw(@AnyInt) -> { @AnyInt.0 } + } in { + boom(@Nat.0) + } +} +""" + + #: The same program with the refinement removed — the spelling whose + #: protection the refined one has to match. + _BARE = _REFINED.replace( + "type AnyInt = { @Int | true };\n\n", "").replace("AnyInt", "Int") + + #: u64.MAX in an i64 slot reads back as -1. + _U64_MAX = 18446744073709551615 + + def test_both_obligations_are_recorded(self) -> None: + """`refine_bind` AND `nat_to_int_coerce`, not one or the other. + + Different kinds describing different facts about one value, so this + is a pair rather than a double-record: the predicate is about which + inhabitants are legal, the coercion about whether the value survives + the u64-to-i64 reinterpretation at all. + """ + kinds = [o.kind for o in _verify(self._REFINED).obligations + if o.kind in ("refine_bind", "nat_to_int_coerce")] + assert kinds == ["refine_bind", "nat_to_int_coerce"], kinds + + def test_both_guards_are_emitted(self) -> None: + """...and codegen emits both, so neither obligation is a promise + the module does not keep.""" + body = wat_fn_body(_compile_ok(self._REFINED).wat, "boom") + assert "contract_fail" in body, body + assert re.search( + r"i64\.const 0\s+i64\.lt_s\s+if\s+unreachable", body, re.S, + ), body + + def test_the_refinement_does_not_weaken_the_boundary(self) -> None: + """The behavioural pin, as a DIFFERENTIAL against the bare spelling. + + Asserting "the refined one traps" alone would be satisfied by a + boundary that traps on everything; asserting it agrees with the bare + spelling is the property that was broken — refined returned -1 where + bare trapped. + """ + with pytest.raises(WasmTrapError): + execute(_compile_ok(self._BARE), fn_name="main", + args=[self._U64_MAX]) + with pytest.raises(WasmTrapError): + execute(_compile_ok(self._REFINED), fn_name="main", + args=[self._U64_MAX]) + + def test_a_value_that_fits_still_passes(self) -> None: + """The over-refusal control: the guard is dead for an in-range value, + on both spellings.""" + assert _run(self._REFINED, "main", [5]) == 5 + assert _run(self._BARE, "main", [5]) == 5 + + class TestAProvedContractSurvivesTheThrowPayload: """The soundness differential: verify says PROVED, so run must agree. diff --git a/vera/README.md b/vera/README.md index e2ee3374..90b1f754 100644 --- a/vera/README.md +++ b/vera/README.md @@ -753,7 +753,7 @@ The `ERROR_CODES` dict in `errors.py` maps every code to a short description (16 ## Test Suite -Testing spans a **pytest suite** of 11,623 tests across 173 files — compiler-internals unit tests plus a **conformance suite** (242 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. +Testing spans a **pytest suite** of 11,633 tests across 173 files — compiler-internals unit tests plus a **conformance suite** (242 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. See **[TESTING.md](../TESTING.md)** for the comprehensive testing reference -- test file table, conformance suite details, compiler code coverage, language feature coverage, helper conventions, validation scripts, CI pipeline, and guidelines for adding tests. diff --git a/vera/verifier.py b/vera/verifier.py index fe5609e9..9e072ec6 100644 --- a/vera/verifier.py +++ b/vera/verifier.py @@ -4413,6 +4413,25 @@ def _obligate_binding_triple( decl, value, refined, smt, slot_env, assumptions, site=site, guarded=refined_guarded, ) + # #820 INTERSECTION, at this boundary too (PR #1325 review). A + # refinement OVER @Int does not imply fit-in-i64 — `< 100` is + # SATISFIED by a reinterpreted negative — so the widen obligation + # is not subsumed by the refined one and rides ALONGSIDE it + # rather than being skipped by the chain below. Measured before + # the fix: `Exn` fed a @Nat of u64.MAX TRAPPED on the widen + # guard, while `Exn<{ @Int | true }>` fed the same value returned + # -1 — adding a refinement silently disabled the protection the + # bare spelling had. Not a double-record: the arms below are + # `elif`, so a value that reaches this branch reaches neither, + # and the two obligations are different kinds (`refine_bind` and + # `nat_to_int_coerce`) describing different facts about one + # value. + if (self._int_widening_target(value, formal) + and self._result_is_nat(value)): + self._check_int_widening_obligation( + decl, value, smt, slot_env, list(assumptions), + site=site, guarded=widen_guarded, + ) elif (self._nat_binding_target(value, formal) and self._narrows_into_nat(value)): self._check_nat_binding_obligation( diff --git a/vera/wasm/calls.py b/vera/wasm/calls.py index 78263610..256314fb 100644 --- a/vera/wasm/calls.py +++ b/vera/wasm/calls.py @@ -599,6 +599,17 @@ def _translate_call( if refined_payload is not None: instructions = self._emit_exn_payload_refine_guard( instructions, refined_payload, cell, call, env) + # #820 INTERSECTION (PR #1325 review): the predicate does + # not imply fit-in-i64, so a refinement OVER `@Int` keeps + # the widening guard BESIDE its predicate guard rather + # than replacing it. Without this, adding a refinement + # weakened the boundary: `Exn` fed a @Nat of u64.MAX + # trapped, `Exn<{ @Int | true }>` returned -1. The + # verifier's `_obligate_binding_triple` records the pair + # in the same shape, so obligation and guard still match + # one-for-one. + if base == "Int" and self._result_is_nat(call.args[0]): + instructions = self._emit_int_widen_guard(instructions) if refined_payload is None and (is_state_put or is_exn_throw): if base == "Nat" and self._narrows_into_nat(call.args[0]): instructions = self._emit_nat_bind_guard(instructions) From 48cb45f61bff9e01b95c4539e05e768d3d27923e Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 21:05:34 +0100 Subject: [PATCH 49/69] Align the two trap-fix comments and the #754 row with the shipped guards The final review pass's two Minors, both verified: the JSON-mode comment on _TRAP_FIX_PARAGRAPHS still named two empty-Fix kinds where the corrected human-mode comment names three; and the #754 row's "sign pair" phrase both omitted the independent widening guard and over-claimed the pair on the refined payload path, which carries the predicate guard (whose lowered check includes the base's range) plus the widening guard. Skip-changelog: comment-only cli.py edit; the KNOWN_ISSUES row wording rides the PR's existing entries Co-Authored-By: Claude --- KNOWN_ISSUES.md | 2 +- vera/cli.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 7deeba31..0a2c59b7 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -35,7 +35,7 @@ Things Vera cannot do yet, as distinct from defects in what it claims to do. | An ADT `decreases` measure is runtime-ranked only when its type's reachable field structure is fully concrete: a parameterized measure (`List`), or a concrete type whose recursion rides through a parameterized field, gets no runtime guard (never a wrong one — the registered generic layout does not describe concrete construction), staying statically checked and Tier-3-disclosed. Per-instantiation `$dec_size_` helpers (the `$eq_` pattern) close it. | [#1177](https://github.com/aallan/vera/issues/1177) | | Tier 2 verification (Z3 guided by `assert`/lemma hints) is specified in §6.3.2 but not implemented, so contracts that need hints fall to Tier 3 runtime checks. Per-monomorphization verification (#732) has since landed; full Tier 2 (hint-guided) stays on the Milestone 4 horizon. | [#427](https://github.com/aallan/vera/issues/427) | | `data invariant(...)` clauses (spec §2.6, §6.2.3) are not implemented — every documented form fails with E130 because the slot environment for the invariant predicate isn't wired up. Refinement types (`{ @T \| predicate }`, §2.6) are the working alternative until this lands. | [#686](https://github.com/aallan/vera/issues/686) | -| **Statically**, every narrowing *binding site* is obligated: a provably-negative value is an E503 error, and an untranslatable narrowing at an unguarded site is an E504 warning — so a program whose narrowings are all statically PROVEN stores no negative `@Nat` at these sites. `vera verify` exits 0 on the E504 warnings too, so verify-clean is the weaker property: a disclosed narrowing is one the static proof did not reach, and at an unguarded site nothing else does either. **At runtime** (an unverified `vera compile`/`run`), the guard covers every concrete *direct* binding site (a nested constructor sub-pattern is the exception, #765 below), generic function-formal calls, and the string/markup builtin `@Nat` parameters (`string_repeat`, `string_pad_start`/`_end`, `string_from_char_code`, `md_has_heading`), but three statically-obligated sites stay unguarded and store a negative silently: the effect-operation argument of a USER-declared effect — the built-in `State` `put`/`resume` boundaries and the built-in `Exn` `throw` payload are obligated and codegen-guarded, the latter with the §2.6.5 predicate guard as well as the sign pair ([#1268](https://github.com/aallan/vera/issues/1268)) — (codegen's `_effect_ops` carries only the dispatch target for the rest), the generic-instantiated constructor field (constructor layouts carry no per-field `@Nat` mono metadata), and the `nat_to_int`/`nat_to_string` conversion builtins (special-cased before the `_fn_nat_params` guard loop) — no runtime guard is emitted at those three sites; the unguarded residual is disclosed statically as an E504 warning instead. (The static obligation now also covers the function **return** position — an `@Int` value narrowing into a `@Nat` return is an E503 obligation backed by a codegen return guard, #758 — and the value-position tuple/constructor component, which is both statically obligated and runtime-guarded at the function boundary.) #820 threaded the per-component target-type table into codegen (the metadata these narrowing guards need); the generic-field and effect-op sites are now unblocked for wiring, though the narrowing direction is not yet guarded here. | [#754](https://github.com/aallan/vera/issues/754), [#757](https://github.com/aallan/vera/issues/757) | +| **Statically**, every narrowing *binding site* is obligated: a provably-negative value is an E503 error, and an untranslatable narrowing at an unguarded site is an E504 warning — so a program whose narrowings are all statically PROVEN stores no negative `@Nat` at these sites. `vera verify` exits 0 on the E504 warnings too, so verify-clean is the weaker property: a disclosed narrowing is one the static proof did not reach, and at an unguarded site nothing else does either. **At runtime** (an unverified `vera compile`/`run`), the guard covers every concrete *direct* binding site (a nested constructor sub-pattern is the exception, #765 below), generic function-formal calls, and the string/markup builtin `@Nat` parameters (`string_repeat`, `string_pad_start`/`_end`, `string_from_char_code`, `md_has_heading`), but three statically-obligated sites stay unguarded and store a negative silently: the effect-operation argument of a USER-declared effect — the built-in `State` `put`/`resume` boundaries and the built-in `Exn` `throw` payload are obligated and codegen-guarded, the latter with the #1203 sign/widening pair on unrefined payloads and, on a refined payload, the §2.6.5 predicate guard (its lowered check includes the base's range) plus the independent `@Nat`-to-`@Int` widening guard ([#1268](https://github.com/aallan/vera/issues/1268)) — (codegen's `_effect_ops` carries only the dispatch target for the rest), the generic-instantiated constructor field (constructor layouts carry no per-field `@Nat` mono metadata), and the `nat_to_int`/`nat_to_string` conversion builtins (special-cased before the `_fn_nat_params` guard loop) — no runtime guard is emitted at those three sites; the unguarded residual is disclosed statically as an E504 warning instead. (The static obligation now also covers the function **return** position — an `@Int` value narrowing into a `@Nat` return is an E503 obligation backed by a codegen return guard, #758 — and the value-position tuple/constructor component, which is both statically obligated and runtime-guarded at the function boundary.) #820 threaded the per-component target-type table into codegen (the metadata these narrowing guards need); the generic-field and effect-op sites are now unblocked for wiring, though the narrowing direction is not yet guarded here. | [#754](https://github.com/aallan/vera/issues/754), [#757](https://github.com/aallan/vera/issues/757) | | A refinement / `@Nat` narrowing bound in a **nested** constructor sub-pattern (`Some(Some(@PosInt))` on `Option>`) is statically obligated — a `vera verify`-clean program is sound; a bad nested refined narrowing is `E505`, a bad nested `@Nat` narrowing is `E503` (#763) — but the runtime guard covers only the `@Nat` nested bind (verified by run probes: a negative payload traps in both direct and closure positions) — the *refined* nested bind is not guarded, so an unverified compile doesn't trap on a violating nested refined payload; the verifier discloses that residual as `tier3_unguarded`/E506 | [#765](https://github.com/aallan/vera/issues/765) | | A refinement whose base carries a **non-plain type argument** (`{ @Array<{ @Int \| ... }> \| array_length(...) > 0 }`, an `Array` base) gets **no runtime guard at any boundary** — `_refinement_guard_parts` cannot spell the binder slot for such a base, so named-function and closure parameters and returns alike leave the predicate unchecked at run time. The verifier discloses this honestly (`tier3_unguarded`, E506, excluded from runtime-checked totals — `_refined_boundary_codegen_guardable` mirrors the codegen bail, KEEP IN SYNC); the guard itself is the open work. Same disclosure family as #754 / #757 / #765. | [#1036](https://github.com/aallan/vera/issues/1036) | | Contracts that depend on a `handle[...]` expression's value fall to Tier 3 — the verifier does not model the `handle[...]` expression's value or handler-clause state, so an `ensures` over a handle result is runtime-checked rather than statically proved. (Primitive-op and binding obligations *inside* the handle body walk at enclosing-scope precision and can prove Tier-1; a clause body's slot-dependent obligations are fresh-scope Tier-3, while a manifest violation there — `5 / 0` — stays a loud compile error.) | [#439](https://github.com/aallan/vera/issues/439) | diff --git a/vera/cli.py b/vera/cli.py index cf08bb5d..12f63f26 100644 --- a/vera/cli.py +++ b/vera/cli.py @@ -1230,7 +1230,9 @@ def cmd_run( # Always present for shape stability (same reasoning # as `trap_kind` and `frames`); empty string for the # kinds that don't admit a generic suggestion - # (`contract_violation`, `unknown`). Mirrors the + # (`contract_violation` and `host_error`, whose + # descriptions already carry the remediation; + # `unknown`). Mirrors the # `fix` field on compile-time `Diagnostic` objects. "fix": exc.fix, } From fd55202157285eb991c6ca774cd72b18fd9e6830 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 17:31:22 +0100 Subject: [PATCH 50/69] Join over the branches in the two Vera-level type namers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit yields a type. Their Vera-level siblings kept the one-branch read: `InferenceMixin._infer_vera_type` (the WASM call-rewrite consultor) read `then_branch` only and `arms[0]` only, and `Monomorphizer._infer_vera_type_name` (the instantiation-discovery consultor) read `then_branch` only and had no `MatchExpr` arm at all. The issue recorded the defect as latent because no check-green program reaching the shape had been constructed. One is constructible, and it is loud in two ways. As an array-literal element, `[if false then { throw(true) } else { 42 }, 7]` names no element type, `_translate_array_lit` raises `CodegenSkip`, and a `public fn main` that is `vera check`- and `vera verify`-green at 2 Tier 1 is simply absent from the compiled exports behind an [E602] note. As a generic argument, `idg(if false then { throw(true) } else { 42 })` — verify-green at 4 Tier 1 — binds nothing, so the clone falls to the phantom-var default: the module carries `idg$Bool`, an i32 clone, reached with an i64 argument, and fails to load with `type mismatch: expected i32, found i64`. The same read through a constructor field mis-instantiates the other way (`expected i64, found i32`). The repair lands on both consultors because the `match` case is broken in both directions: with every arm completing and nothing diverging, the rewrite named `idg$Int` from arm 0 while discovery, having no arm for `MatchExpr`, named the phantom default, and the caller was dropped on a dangling target. The clone-name agreement contract (#772) makes the pair, not either function, the unit of repair. Seven witnesses in `tests/test_infer_vera_type_join_1286.py`, each asserted on the value, on `main` surviving into the exports, and on the absence of the skip note, and each paired with its arm-swapped twin so the property under test is order-invariance rather than a remembered value; a WAT assertion pins which clone the module carries. Reverting any one of the four edits turns the file red (9 / 4 / 5 / 6 failures), all four at once fails all 18. `ch02_generic_arg_branch_join` promotes the witness into the conformance suite at level `run`. All 287 pre-existing corpus programs emit byte-identical WAT, re-measured against this base. Closes #1286 Co-Authored-By: Claude --- CHANGELOG.md | 2 + KNOWN_ISSUES.md | 1 - TESTING.md | 3 +- .../ch02_generic_arg_branch_join.vera | 31 ++ tests/conformance/manifest.json | 17 + tests/test_infer_vera_type_join_1286.py | 421 ++++++++++++++++++ tests/test_walker_defensive_branches_597.py | 9 +- vera/monomorphize.py | 25 +- vera/wasm/inference.py | 34 +- 9 files changed, 532 insertions(+), 11 deletions(-) create mode 100644 tests/conformance/ch02_generic_arg_branch_join.vera create mode 100644 tests/test_infer_vera_type_join_1286.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cad0aa12..d1d08ace 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **The `ch09_json` conformance entry cites the section that exists.** Its manifest `spec_ref` read `Section 9.4.4`, a section Chapter 9 does not have — the chapter's `9.4` runs to `9.4.3 Map` and `Json` is `9.7.1`. +- **The Vera-level type namers join over a conditional instead of reading one branch** ([#1286](https://github.com/aallan/vera/issues/1286)). [#1276](https://github.com/aallan/vera/issues/1276) fixed the WAT result-type deciders to take the first branch that yields a type; their Vera-level siblings kept the one-branch read — `InferenceMixin._infer_vera_type` (the WASM call-rewrite consultor) read `then_branch` only and `arms[0]` only, and `Monomorphizer._infer_vera_type_name` (the instantiation-discovery consultor) read `then_branch` only and had no `MatchExpr` arm at all. A branch whose every path `throw`s names no type, so reading only that branch answered "unknown" for the whole expression, and the issue's latency estimate was wrong in the program's favour: the shape is constructible, and it is loud in two different ways from check-green source. As an **array-literal element** (`[if false then { throw(true) } else { 42 }, 7]`, and the `match` and `String` spellings of the same position) the unknown element type raised `CodegenSkip`, so a declared `public fn main` — `vera check`-green and `vera verify`-green at 2 Tier 1 — was absent from the compiled exports behind an `[E602]` note. As a **generic argument** (`idg(if false then { throw(true) } else { 42 })`, verify-green at 4 Tier 1) the type variable bound nothing and the clone fell to the phantom-var default: the module carried `idg$Bool`, an i32 clone, reached with an i64 argument, and failed to load with `Invalid input WebAssembly code at offset 73: type mismatch: expected i32, found i64`. The same reading through a constructor **field** mis-instantiated the unboxing clone the other way round (`expected i64, found i32`). The repair lands on both consultors together because the `match` case was broken in both directions: with every arm completing and nothing diverging, the rewrite named `idg$Int` from arm 0 while discovery, having no arm for `MatchExpr`, named the phantom default, and the caller was dropped on a dangling target — the clone-name agreement contract ([#772](https://github.com/aallan/vera/issues/772)) makes the pair, not either function, the unit of repair. All 287 pre-existing corpus programs (`examples/` plus `tests/conformance/`, recursive) emit byte-identical WAT, since the join only changes an answer that was previously unknown; `ch02_generic_arg_branch_join` promotes the witness into the conformance suite at level `run`. + - **A user-defined `fn get` / `fn put` is no longer hijacked by an enclosing handler** ([#1284](https://github.com/aallan/vera/issues/1284)). Three sites answered "does this `get` mean the user's declaration or the effect operation?" independently. The checker answers user-fn-first — `_check_call_with_args` looks a bare name up as a function before it looks it up as an operation, so a declaration named `get` owns every bare `get(...)` in its scope, which an arity or argument-type error at such a call site proves by reporting the *user's* signature (E201/E202). Codegen answered twice more: the declared-effect row in `vera/codegen/functions.py` withheld the intrinsic when `_fn_sigs` already owned the name, and the handler expression in `vera/wasm/calls_handlers.py` installed `get`/`put` unconditionally. From `vera check`-green source that produced, depending on the nesting shape, a **silently wrong value** (`nat_to_int(get(3))` under `handle[State](@Int = 5)` returned the cell's 5 for the function's 4, and the argument was not even emitted), a **module WASM validation rejects** (a `@Bool`-returning user `get` took `state_get_Int`'s i64 into an `i32` position; different-family nesting took the *enclosing* cell's getter at the wrong width), or a **spurious `[E602]`** in which the #1233 unaddressable-cell gate refused `main` outright, naming "a bare or qualified State operation `get`" the program never contained. The repair is one predicate, `vera.slots.bare_call_denotes_user_fn`, stating the checker's rule once and consumed by the bare-call dispatch in `vera/wasm/calls.py` (which now gates the clause-inline registry, the host-cell intrinsics and the addressability gate together), by the three bare-`FnCall` result-type inference sites, and by the monomorphizer's discovery walk — each passing its own name table, so the sites cannot answer differently about the table they share. Gating the *dispatch* rather than the *registries* is what makes it correct rather than merely consistent: the registries record which cell an op name reaches, which is true whatever the program's declarations are called, and withholding an entry answered both questions with one table. That is why the gate-only fix measured during PR #1283's review turned the loud skip into a differently-broken module, and why it also cost the qualified spelling its cell — `State.put(5)` in a function that also declares `fn put` compiled to `call $vera.put` and failed to link, which now lowers to the intrinsic the checker always meant. Discovery's `MonoContext.fn_names` moves to the same lookup-time question, so a `get(())` fixing a generic's type variable under a handler names the clone the rewrite emits. All 256 conformance and example programs emit byte-identical WAT. **The `W002` async-commutativity warning was the same defect in the checker's own file** and is corrected with them: `_collect_expr_effects` asked `lookup_effect_op` before the scoped function lookup — the last op-first consumer — so a user function named after an operation contributed the *operation's* parent effect to the commutativity analysis instead of its own declared row, wrong in both directions. A **pure** `fn get`, in a program containing no `State` at all, drew `async argument performs State effects`; a `fn get` that performs `IO`, under a row naming `Http` first, drew **no warning**, because the walk bound the name to `Http.get`, which is inside the commutative whitelist, and silently withheld the eager-evaluation warning the program is owed. Both are pinned with rename controls — the byte-identical program with the helper called `gett` / `fetch` was correct throughout — beside a control that an unshadowed bare `get(())` under a `State` row still warns, so the fix cannot degenerate into never reporting `State`. The walk's comment claiming it resolves "like the call checker above" is now true. One caveat the predicate did not close on its own: codegen's name table was not scope-accurate, so a name the call site cannot see still answered "user-owned" there — a property of the table rather than of the rule, closed by [#1299](https://github.com/aallan/vera/issues/1299) below. - **A bare call is lowered against the names its call site can see** ([#1299](https://github.com/aallan/vera/issues/1299)). The [#1284](https://github.com/aallan/vera/issues/1284) ownership predicate is one rule read over two tables, and only one of them was a scope. The checker's is a lexical walk; codegen passed `set(_fn_sigs.keys())` — a flat mirror of every symbol the whole compilation absorbed — so a bare `get(())` the checker had resolved to a `State` operation was lowered as a call to a declaration the body cannot name. Four source shapes reach it, all `vera check`-green and all one defect: an imported module's **private** `fn get` (invisible, but still compiled in because the module's own bodies call it), a **public** one a selective import excludes, a `where` helper of a **`forall` parent** (which keeps a bare `_fn_sigs` key beside its clone-qualified one where a non-generic parent's helper does not), and the ability operation `show`, which `E151` does not reserve and which reaches the same table through the *intrinsic* gate rather than the operation one. How it lands is a property of the widths, not of the route: where the invisible declaration and the cell share a WAT type the module loads and returns the wrong value (7007 where the cell holds 42007), where they differ it fails to load (`type mismatch: expected i64, found i32`), and the generic-`where` route is always loud — the bare key exists in the signature table while no bare *symbol* is emitted, so the call dies at WAT assembly on `unknown func: failed to find name $get` with no E-code. The repair splits the two questions the one set was answering. `_known_fns` keeps the flat registry for `_translate_call`'s guard rail, which asks whether a *resolved* target — already mono-mangled, already `mod$…` rerouted — has an implementation, and is flat by nature. A new `_scoped_fns` carries the names visible in the compiling declaration's **lexical** scope, and that is what the ownership predicate reads: its namespace's own declarations plus the public, in-filter names of the imports *that namespace* makes (spec §8.6.4 — imports are never inherited, so a transitively-reached module contributes nothing to the entry program), the prelude, and the `where` helpers of every enclosing function. Module scope alone would not have closed the third route: a generic's helper *is* in the module and still is not in a sibling's scope. The narrowing is a strict subset of the registry by construction — every `$`-bearing key is admitted unconditionally, since `$` cannot occur in a Vera identifier and a mangled name is never what a bare source call spells — so it can only withdraw a name the flat table wrongly claimed. diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 0a2c59b7..7702568a 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -22,7 +22,6 @@ Defects in shipped compiler, runtime, or tooling behaviour — this table matche | Spec §1.4 says its reserved keywords MUST NOT be used as function names, and for **seventeen of them nothing enforces it**: `then`, `else`, `data`, `type`, `module`, `import`, `public`, `private`, `requires`, `ensures`, `invariant`, `decreases`, `effect`, `with`, `in`, `where` and `pure` all declare and pass `vera check` as `public fn (@Int -> @Int)`. (`handle` is an eighteenth acceptance and is the sanctioned host-entry-point carve-out, §5.2; the other eleven names — `resume`, `exists`, `forall`, `match`, `assert`, `assume`, `fn`, `let`, `if`, `true`, `false` — are refused **E153**.) They are not even traps: `private fn with(@Int -> @Int)` and its `where` / `type` / `pure` / `requires` / `import` siblings are **callable** from the same file, so the declaration resolves and runs like any other function. The comment above `_KEYWORD_FN_NAMES` in `vera/checker/registration.py` gives the opposite as the reason those names are absent from the set — that the contextual lexer "does not admit them as a function name, so no declaration reaches this checker at all" — so the omission rests on a premise the tree refutes, and neither the parser nor the checker holds the MUST. Two resolutions, and the choice is a ruling rather than a mechanical fix: extend `_KEYWORD_FN_NAMES` so the seventeen join `resume` under E153, or narrow §1.4's MUST to the names actually reserved. The `resume` precedent leans to enforcing — `resume` is likewise not a keyword token anywhere and likewise parses, and was reserved *at the checker* precisely because the parser had no keyword to refuse it with — and E153's own rationale, that a keyword names one construct and nothing else (DESIGN principle 3), does not depend on the name being a trap. Until the ruling lands, §1.4's `handle` carve-out sentence deliberately makes no enforcement claim, so the spec does not assert a MUST it cannot back. | [#1296](https://github.com/aallan/vera/issues/1296) | | Spec Chapter 10 is a second, hand-maintained copy of `vera/grammar.lark`, and the rule-name alignment gate `scripts/check_grammar_alignment.py` compares only rule-name headers — three classes of drift pass it unseen. **Terminals, both directions**: the header pattern requires a lowercase lead, so a fabricated terminal added to §10.2 leaves the gate green, and no declared-versus-referenced audit exists in either direction. **Rule references**: restoring a removed ambiguity to a production's right-hand side is invisible, because only headers are compared. **Production bodies**: the class most grammar edits actually fall into. Two instances are live in the chapter on `main` today, both body-level and both this issue's to close: typed holes (`"?" -> hole_expr`, in `grammar.lark` since 2026-03-30) appear nowhere in `primary_expr`, and §10.2's `BLOCK_COMMENT: /\{-[\s\S]*?-\}/` is non-nesting, contradicting both §1.3 ("They nest") and the implementation, which parses `{- a {- b -} c -}` clean. Never a wrong answer from a program — the defect is that the published grammar misdescribes the one the parser has. Fix direction: extend the gate side-aware (terminal audit both ways, reference-set comparison), or fold Chapter 10 toward the DESIGN Grammar row's actual promise of a single-sourced shared grammar rather than a hand-maintained copy held honest by ever-wider cross-checks; the two live instances are fixable independently of which direction wins. | [#1290](https://github.com/aallan/vera/issues/1290) | | `.github/workflows/release.yml`'s `Tag and create GitHub Release` step fails with `HTTP 422: Validation Failed — body is too long (maximum is 125000 characters)` when the CHANGELOG section `scripts/release.py notes` extracts into `RELEASE_NOTES.md` exceeds GitHub's release-body limit. It fired on v0.1.10, whose notes extract to roughly 148,700 bytes — the #1213 burndown's 44-issue section, some 23,000 characters past the limit — and it fired at the worst point in the pipeline: **after** PyPI had accepted the immutable archives and **after** the tag was created, leaving the release half-cut with no repeatable path back. The v0.1.10 GitHub Release was completed by hand, mirroring the step exactly: the run's artifacts downloaded and hash-verified three ways, then `gh release create --verify-tag --latest` with the wheel, sdist and SHA256SUMS, and a generated body — the section's bold bullet lead-ins as a headline index plus a link to the canonical section at the tag. Rare, since it needs a release this large, but rarity is not the mitigating factor here; the landing point is. Fix direction: make the step total — before `gh release create`, regenerate oversized notes into that index form so the release always carries a body that fits, with the full notes staying in `CHANGELOG.md`, which is already the release notes of record. | [#1288](https://github.com/aallan/vera/issues/1288) | -| `_infer_vera_type` (monomorphization's Vera-level type namer) still reads only `then_branch` of an `IfExpr` and only `arms[0]` of a `MatchExpr` — the #1276 join fix landed in its WAT-side siblings but not here. Confirmed by inspection; latent: a wrong answer needs an instantiation discovered through a divergent-first-arm conditional, and no check-green program reaching that shape has been constructed (the checker's upstream unification constrains the same joins). The fix is the mechanical #1276 mirror — take the first arm that yields a type — landing with the failing program that proves it, per the test-first rule. | [#1286](https://github.com/aallan/vera/issues/1286) | | `ch05_closure_nat_return` (a run-level conformance program in the pre-commit + CI gate) trapped **once** in a full `check_conformance.py` run (`unreachable` in `main` — the sentinel `assert` or a GC shadow-stack guard) and has not reproduced in ~960 attempts across isolated, parallel, eager-GC, and hash-seed-swept executions; the emitted WAT is deterministic and correct. Suspected rare runtime/GC/wasmtime interaction, tracked so a future intermittent CI red resolves here instead of starting fresh. | [#996](https://github.com/aallan/vera/issues/996) | ## Limitations diff --git a/TESTING.md b/TESTING.md index ccb398fa..43786c0f 100644 --- a/TESTING.md +++ b/TESTING.md @@ -93,6 +93,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_lexical_fn_scope_1299.py` | 56 | 1,554 | #1299 — codegen's bare-call ownership table must be the CALL SITE's lexical scope. The #1284 predicate is one rule over two tables, and codegen's was `set(_fn_sigs)`: every symbol the whole compilation absorbed, including names the compiling body cannot see, so a bare `get(())` the checker resolved to a `State` operation was lowered as a call to some other declaration. Four routes, all check-green — an imported module's **private** `get`, a **public** one a selective import excludes, a `where` helper of a **`forall` parent** (which keeps a bare key beside its clone-qualified one where a non-generic parent's does not), and the ability operation `show`, which E151 does not reserve and which reaches the same table through the INTRINSIC gate rather than the op one. Where the widths agreed the module loaded and answered the invisible declaration's value (7007 for the cell's 42007); where they differed it failed to load; the generic-`where` route is always loud (`unknown func $get`). Every expected value is the checker's, PROVEN by a type oracle rather than assumed — the invisible `get` returns `@Bool` while the caller returns `@Int` from it and checks green — and each route carries a rename control. The visibility matrix (public/private × in-filter/excluded/wildcard × shadowed/unshadowed × direct/transitive) asserts the verify verdict and the runtime value together per cell. The two directions are pinned at once: the sibling loses the name, the generic TEMPLATE keeps it (asserted on the emitted instruction stream, since monomorphization supersedes the template and a value assertion would be green either way), and a lifted closure — compiled through its own `WasmContext` — inherits its parent's scope. Four table invariants sit beside them: the scoped set is a subset of the registry, every `$`-bearing key stays in it, prelude names stay in it, and every emission door supplies a declaration its own helpers | | `test_phantom_generic_instances_1271.py` | 14 | 358 | #1271 — discovery inside a still-generic scope must not instantiate a callee at an ENCLOSING scope's type VARIABLE. `pick(@U.1, @U.0)` inside `forall fn helper` bound `pick`'s variable to the NAME `U`, so a `pick$U` clone was emitted whose parameter has no WASM type and which the compilability pass then skipped with a loud `[E604]` — the noise that kept #1223's shapes out of the conformance suite. Drives the four #1223 fixtures plus a mutual-recursion shape (two sibling generic helpers under a generic parent, whose phantoms include one arriving through a callee's declared RETURN type, `leaf$W`), asserting on ONE compile that no clone is keyed by a type variable, that no E602/E604/E605 skip is emitted, AND that the genuinely concrete clone is still there — the third assertion being what separates the filter from an over-filter that would take the real instantiation with it. Plus the **primitive-spelled binder** matrix (`forall`, ``, ``, ``), each row instantiating a sibling at exactly the type its binder is spelled like — a shared `idw(5)` would have let every row but `Int` pass for free — asserted on the clone set AND on the program still running; with the `Q`-binder control that keeps a genuine type variable filtered, so the fix cannot degenerate into "never filter". That control CREATES a live phantom candidate — a generic helper under a generic parent, handing its callee an argument typed by its own binder — because a control that merely fails to create one holds under any filter including none; mutation-checked by disabling the filter, which turns it red | | `test_handle_exn_divergent_result_1276.py` | 10 | 391 | #1276 — a `handle[Exn]` whose clause body AND handled body both diverge emitted a result-LESS `block` into a result-expecting context: check-green, verify-green, rejected at load with `type mismatch: expected i64 but nothing on stack`. Four divergent shapes (the issue's Int rethrow, the `Byte` payload spelling #1269 unmasked, a three-deep rethrow chain, and a clause diverging through both arms of an `if`), each asserted on valid WASM AND on the observable — the OUTER handler's clause value, 1000. Paired with the Unit TWIN, which infers `None` for the same reason but DOES complete: it must keep running and its WAT must contain no `unreachable` at all. The pairing is the point — `result_wt is None` means two things wanting opposite lowerings, and a fix that terminated both would trap a program that runs. The MIRROR family covers a clause that throws on one path and COMPLETES on the other (`if` and `match` spellings), where the inference read only the `then` branch / arm 0, answered `None`, and left the completing path's value stranded in a result-less block; the `if` case appears twice with different thrown values so both the throwing and the completing path are exercised from one inference | +| `test_infer_vera_type_join_1286.py` | 18 | 421 | #1286 — the VERA-level siblings of #1276's WAT join. `InferenceMixin._infer_vera_type` (the WASM call-rewrite consultor) read `then_branch` only and `arms[0]` only, and `Monomorphizer._infer_vera_type_name` (the instantiation-discovery consultor) read `then_branch` only and had no `MatchExpr` arm at all — so a branch that throws, naming no type, decided the answer for the whole expression. Two symptoms from check-green (and, with contracts, verify-green) source: as an array-literal ELEMENT the `None` raised `CodegenSkip` and the declared `main` simply left the exports with a loud [E602] note, and as a GENERIC ARGUMENT it left the type variable unbound, so `idg$Bool` — the phantom-var default, an i32 clone — was emitted for an i64 `Int` argument and the module failed to load. Seven witnesses (array literal in the `if`, `match` and pair-representation `String` spellings; generic argument in the `if` and `match` spellings; the constructor FIELD behind the same conditional), each asserted on the value, on `main` surviving into the exports, and on the absence of the skip note — the drop is quiet at the value level once the function is gone. The seventh witness is the consultor-AGREEMENT case, where every arm completes and nothing diverges: the rewrite named `idg$Int` from arm 0 while discovery named the phantom default, and the caller was dropped on a dangling target — which is why the repair lands on both consultors together, the clone-name agreement contract (#772) making the pair the unit. Each witness carries its ARM-SWAPPED twin and the pair must agree, so the join property under test is order-invariance rather than a remembered value; a WAT assertion pins WHICH clone the module carries, since a value can be right for the wrong reason. Mutation-checked one edit at a time: reverting the rewrite-side `if` fails 9, its `match` 4, the discovery-side `if` 5, the discovery-side `match` 6, and all four at once fails all 18 | | `test_generic_under_generic_callees_1223.py` | 8 | 298 | #1223 — a generic `where`-helper under a GENERIC parent instantiates its own generic callees. The helper is monomorphized only during clone hoisting, outside the worklist that rescans every clone it emits, so a top-level generic called from the helper body was discovered only in its still-generic spelling (`pick$U`, binding the enclosing type variable's NAME) while the rewrite called `pick$Bool` — E602 skip, E620 drop of the parent and of `main`, "No exported functions" from a check-clean, verify-clean program. Four shapes — a user generic, the prelude twin (`option_unwrap_or`), two levels of generic nesting where the INNER helper is the caller, and the non-generic-parent control that compiled before the fix and must keep compiling (it is what proves the trigger is the generic ancestor rather than the nested helper) — each asserted on no E602/E620, the checker's run value (the helper's argument order is non-commutative, so a miswiring gives 7 instead of 3), and a REGISTERED-vs-RESOLVED differential. That differential captures the emitted mono-decl names rather than `_emitted_instances` (whose generic-under-generic entries are keyed by the concrete-free lexical chain, not by the per-clone emission name the rewrite calls) and captures the rewrite side on `_resolve_generic_call` rather than from the WAT, because a desync skips the calling function and removes the dangling `call` along with it. The verifier's half of the pair is pinned in `test_monomorphize_differential.py`'s inline corpus, not here | | `test_mono_effect_op_naming_1207.py` | 9 | 339 | #1207 — monomorphization discovery and the WASM call-rewrite name ONE clone when an effect operation fixes a generic's type argument. A differential over the two consultors, not a unit test on either: the compiler's own E602 ("call target not registered in this module") IS the two sides disagreeing, so each case asserts no E602/E620, and additionally pins WHICH name they agreed on — an alignment on the wrong one still fails. Four instantiation-driving shapes (`get(())` as an array-literal element under a plain `State` cell, under a `type Count = Nat` alias cell whose clone must be `pick$Count`, in a function whose operation comes from the DECLARED effect row rather than an enclosing `handle`, and in direct argument position), plus the `array_append` builtin-argument control and a shadowed-name control — a user `get(@Unit -> @Bool)` is NOT an effect op in a declared row, so the clone must be `pick$Bool` and not the cell's; that case is green before the fix as well as after, which is what makes it a guard against the alignment over-reaching rather than a second copy of the repro | | `test_effect_op_determinism.py` | 9 | 504 | #1215 — bare effect-op resolution order: the built-in `State` and `Http` both declare `get`, so `effects(, Http>)` is a two-candidate row with no user `effect` declaration needed. The two candidate bindings are made to produce DIFFERENT observables (the source-order program runs to `70`; the reversed row is a loud `E217` naming `Http.get`), swept across six `PYTHONHASHSEED` values in child interpreters so a frozenset-order flip cannot pass — plus the innermost-handler-beats-declared-row precedence case, a signature-level assertion that the resolved `OpInfo` follows the recorded order both ways (including the deterministic name tiebreak for a row member no order tuple mentions), and the qualified-lookup control. The type-ARGUMENT sibling rides here too: `effects(, State>)` (two independent cells, spec §7.3.3) had the identical frozenset dependence in `_effect_type_mapping`, and codegen took the LAST instantiation in the row where the checker takes the first — both now source-order-first, swept the same way. A sixth sweep covers the public `ordered_effect_row()` fallback for a row member no order tuple mentions: its two members share the effect NAME and differ only in type ARGUMENT, so a name-only sort key ties them and hands back frozenset order — the order AND the `_effect_type_mapping` selection it drives are both asserted stable across the same seeds. Two further sweeps take that structural key down a level: a type argument may itself be a FUNCTION type, whose own effect row was rendered by `pretty_effect` — so two outer instances differing only INSIDE a nested row (by a refinement's predicate, or by a type variable's built-in marker) tied again, and both legs are asserted single-outcome across the same seeds, separately, so a regression names the elision that came back | @@ -184,7 +185,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_codegen_modules.py` | 186 | 3,835 | Cross-module guard rail, cross-module codegen, module-qualified call resolution bypassing a local shadow incl. intra-module siblings, where-fn helpers both directions, unit- and pair-returning calls in statement position, and the `@Nat`-parameter guard mirrored onto shadowed targets (#814 §8.5.3), name collision detection (E608/E609/E610), fused-async `await` of a cross-module future (#841/#842) and of an indirectly-called closure (#843 — `await(apply_fn(closure, …))` classified by declared return type incl. generic-alias type-arg substitution; unresolvable shapes fail loud via E616 or the #867 WASM-validation trap), transitive module imports (#890 — a `main -> mid -> base` chain and a `main -> {left, right} -> base` diamond compile and run via the real on-disk resolver, while a transitive symbol stays invisible to the top-level importer per §8.6.4), a non-void `ModuleCall` used *directly* as a constructor argument or array-literal element (#905 — its return type is resolved at the field/element site via the shared `_resolve_module_call_wasm_name`, closing a check-green→codegen-crash gap; the non-void sibling of #902's Unit-field fix), a user-defined function named `show`/`hash` used in a constructor field or array element (#908 — the ability-op width special-case must defer to the user fn's declared return width when the name resolves to a user fn, matching codegen's `not in known_fns` dispatch gate; a genuine unshadowed ability op still uses the special-case width), and an imported function's nested `where`-helper at any depth (#989 — the registration walk must reach grandchildren via `_flatten_where_fns`, so an imported `libfn -> child -> grandchild` chain that checks + verifies green also emits every helper and no call dangles) | | `test_codegen_coverage.py` | 5 | 244 | Defensive error paths: E600, E601, E605, E606, unknown module calls | | `test_execute_characterization.py` | 24 | 510 | Characterization harness pinning `execute()`'s observable contract ahead of the #421 runtime decomposition (#734): every `ExecuteResult` field (`value` int/float/str — including a transparent `Future` return decoded for display (#1047) — heap-pointer/None, `stdout`, `state`, `exit_code`, `stderr`) crossed with the three completion modes — normal return, WASM trap (raises `WasmTrapError` with a classified `kind`, output-before-trap preserved), and interrupt/exit (`IO.exit(n)` → `exit_code` n with `value` None, Ctrl-C → 130) — plus the positional-constructor compatibility shape and `capture_stderr` True-vs-default. **Mutation-validated**: every cell confirmed to flip RED when its target return path in `api.py` is deliberately broken (9 mutations, 0 green-for-the-wrong-reason tests) | -| `test_walker_defensive_branches_597.py` | 34 | 848 | Synthetic-AST tests for the 11 defensive `isinstance` branches added by #597 (`_scan_io_ops` / `_scan_expr_for_handlers` / `_infer_expr_wasm_type` / `_infer_vera_type`) plus the 5 pr-review fixes (#2/#3/#8 — ModuleCall/AnonFn/QualifiedCall return None; dead `is not None` guards on Block/HandleExpr removed). Also hosts the two compilability pre-scans' **field-coverage gate** (#1210 rounds 5, 7 and 9): it derives the obligations from the dataclass fields of `vera/ast.py` — one per `(class, field)` PAIR — and a pair is discharged only by the conjunction (the class `isinstance`-branched AND the field name read inside that branch) or by a justified-ignore entry naming the route its expressions ARE reached by. Stronger than `scripts/check_walker_coverage.py`, whose set is the `Expr` subclasses and whose verdict is "the class is NAMED"; and stronger than the class-keyed obligation it replaced, which discharged the moment a class had ANY branch, leaving a new field on an already-dispatched class to a second, weaker screen (the mutation is pinned: fabricating one must fail the OBLIGATION). The ignore table takes either key shape, and a class-level entry is permitted only while its class has a single field — otherwise it would exempt whatever field the class grows next, the same hole one table down. The name-based limit of the dispatch route is stated on the gate. Round seven adds the **boundary-guard derivation gate**: the emitter, the return-epilogue predicate and the import pre-scan must all read one tuple decomposition and none may reclassify behind it, which is the structure that makes their agreement a property of one function rather than of three. Plus `contract_exprs`'s explicit dispatch, including the raise on an unknown `ast.Contract` subclass | +| `test_walker_defensive_branches_597.py` | 34 | 855 | Synthetic-AST tests for the 11 defensive `isinstance` branches added by #597 (`_scan_io_ops` / `_scan_expr_for_handlers` / `_infer_expr_wasm_type` / `_infer_vera_type`) plus the 5 pr-review fixes (#2/#3/#8 — ModuleCall/AnonFn/QualifiedCall return None; dead `is not None` guards on Block/HandleExpr removed). Also hosts the two compilability pre-scans' **field-coverage gate** (#1210 rounds 5, 7 and 9): it derives the obligations from the dataclass fields of `vera/ast.py` — one per `(class, field)` PAIR — and a pair is discharged only by the conjunction (the class `isinstance`-branched AND the field name read inside that branch) or by a justified-ignore entry naming the route its expressions ARE reached by. Stronger than `scripts/check_walker_coverage.py`, whose set is the `Expr` subclasses and whose verdict is "the class is NAMED"; and stronger than the class-keyed obligation it replaced, which discharged the moment a class had ANY branch, leaving a new field on an already-dispatched class to a second, weaker screen (the mutation is pinned: fabricating one must fail the OBLIGATION). The ignore table takes either key shape, and a class-level entry is permitted only while its class has a single field — otherwise it would exempt whatever field the class grows next, the same hole one table down. The name-based limit of the dispatch route is stated on the gate. Round seven adds the **boundary-guard derivation gate**: the emitter, the return-epilogue predicate and the import pre-scan must all read one tuple decomposition and none may reclassify behind it, which is the structure that makes their agreement a property of one function rather than of three. Plus `contract_exprs`'s explicit dispatch, including the raise on an unknown `ast.Contract` subclass | | `test_check_walker_coverage_597.py` | 15 | 311 | Unit tests for `scripts/check_walker_coverage.py` parsing logic — Expr subclass extraction, isinstance flattening (incl. tuple form), checklist-block anchoring (incl. CR-3 regression test: `# Foo → bar` outside WALKER_COVERAGE block not counted), section-header tolerance, auto-discovery invariants, end-to-end main exit code | | `test_diagnostic_fields.py` | 90 | 1438 | Unit tests for `scripts/check_diagnostic_fields.py` (#682) — required-field detection, the warning severity rule (no `fix`), spec_ref validity, the codegen structural-exemption registry, the `# diag-fields-exempt` per-call opt-out, the error_code-registration check (#828), a live-tree integration check that all of `vera/` is fully tagged, and the narrowed plumbing-skip (#827: the skip now requires a genuine `self`-receiver helper *method* — a direct class member, not a `@staticmethod`, module-level or nested look-alike — whose **sole own-scope** `Diagnostic` it is, where own-scope means the body and excludes decorators / parameter defaults / annotations; a stray second ctor, or one in a nested `def`, is inspected by all three passes: field presence, `spec_ref` validity, `error_code` registration; each guard and each pass's use of the skip is mutation-pinned separately; #956: the skip also requires that sole ctor be reachable as the helper's result — return-ed, appended, or bound to a local later return-ed/appended — not merely constructed and handed to something else, a name rebound by any binding form — counted generically: every Store-context name (assignment/unpack of any shape, `for`/`with` targets, walrus), `import ... as`, `except ... as`, `match` captures, parameters, and `nonlocal` declarations in nested functions; a bare annotation is not a rebind, and the return/append name-match is order-sensitive — after that binding is treated as unreliable rather than reachable, and only a `self..append(...)` call counts as a diagnostic sink, not an append to an unrelated throwaway local; #955: the `# diag-fields-exempt` opt-out is honoured for an *unresolvable* non-literal severity/spec_ref — marker found anywhere across the call's span — but never for a spec_ref/error_code that resolves yet is factually wrong, and the error_code pass skips non-literal codes entirely) | | `test_stress.py` | 16 | 553 | Scale-dependent regression tests (#596) — `@pytest.mark.stress`, skipped by default. 9 logical tests × eager-GC lane parametrisation = 16 test instances. 10K `array_map`, 5K nested-array `array_map`, 1K-deep tail recursion with allocating arg, 1M-deep tail recursion with allocating arg (#549 GC-aware TCO), 20×20 nested array-fold-of-array-fold, 100K `array_fold`, 10K String allocations, 1K `State` get/put cycles, 10K `IO.print` calls. Pins #570 / #515 / #593 / #549 / #487 / #348 / #573 regression coverage | diff --git a/tests/conformance/ch02_generic_arg_branch_join.vera b/tests/conformance/ch02_generic_arg_branch_join.vera new file mode 100644 index 00000000..6aa452c9 --- /dev/null +++ b/tests/conformance/ch02_generic_arg_branch_join.vera @@ -0,0 +1,31 @@ +-- Conformance: a generic instantiation carried by a LATER branch (Chapter 2) +-- Tests: monomorphization discovers the type argument through a conditional +-- whose FIRST branch diverges — the `then` branch and the first `match` arm +-- both `throw`, so they name no type, and the instantiation is the one the +-- completing branch carries. The array literal in the same position pins the +-- element-layout consultor, which reads the first element the same way. +-- Reading one branch named nothing, and the clone fell to the phantom-var +-- default while the call passed the real type (#1286 — the Vera-level sibling +-- of #1276's WAT result-type join). +private forall fn idg(@T -> @T) + requires(true) + ensures(@T.result == @T.0) + effects(pure) +{ + @T.0 +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(@Int.result == 52) + effects(pure) +{ + handle[Exn] { + throw(@Bool) -> { 0 } + } in { + let @Int = idg(if false then { throw(true) } else { 42 }); + let @Int = idg(match Some(3) { None -> throw(true), Some(@Int) -> @Int.0 }); + let @Array = [if false then { throw(true) } else { 46 }, 7]; + @Int.1 + @Int.0 + @Array.0[1] + } +} diff --git a/tests/conformance/manifest.json b/tests/conformance/manifest.json index 5989833d..db8ab090 100644 --- a/tests/conformance/manifest.json +++ b/tests/conformance/manifest.json @@ -2935,6 +2935,23 @@ "nat" ] }, + { + "id": "ch02_generic_arg_branch_join", + "file": "ch02_generic_arg_branch_join.vera", + "chapter": 2, + "title": "Generic instantiation carried by a later branch", + "level": "run", + "spec_ref": "Section 2.5", + "features": [ + "generics", + "forall", + "monomorphization", + "if_expression", + "match", + "array_literal", + "exn_effect" + ] + }, { "id": "ch03_slot_alias_type_argument", "file": "ch03_slot_alias_type_argument.vera", diff --git a/tests/test_infer_vera_type_join_1286.py b/tests/test_infer_vera_type_join_1286.py new file mode 100644 index 00000000..d7f652c5 --- /dev/null +++ b/tests/test_infer_vera_type_join_1286.py @@ -0,0 +1,421 @@ +"""#1286: the Vera-level type namers must join over `if` / `match`, not read +one branch. + +#1276 fixed the WAT result-type deciders (`_infer_expr_wasm_type`, +`_infer_block_result_type`, `_infer_match_result_type`) to take the FIRST +branch that yields a type. Their Vera-level siblings — the two consultors that +name the type of an expression for array layout and for generic clone naming — +kept the one-branch read: + +* `InferenceMixin._infer_vera_type` (vera/wasm/inference.py, the WASM + call-rewrite side) read `then_branch` only, and `arms[0]` only; +* `Monomorphizer._infer_vera_type_name` (vera/monomorphize.py, the + instantiation-discovery side) read `then_branch` only, and had no + `MatchExpr` arm at all. + +A branch whose every path `throw`s names no type. Reading only that branch +answered `None` for the whole expression, and the two symptoms below both +arrived from check-green (and, where a contract is present, verify-green) +source: + +* as an **array-literal element**, `None` raised `CodegenSkip` and the whole + enclosing function was dropped with the loud [E602] note — a declared + `public fn main` that is simply not in the exports; +* as a **generic argument**, `None` left the type variable unbound, so + discovery fell to the phantom-var default and emitted `idg$Bool` for an + `Int` argument: `Invalid input WebAssembly code ... type mismatch: expected + i32, found i64` at load. + +The `match`-argument case was broken in BOTH directions, which is why the fix +lands on both consultors together: with every arm completing, the rewrite named +`idg$Int` from arm 0 while discovery — having no arm for `MatchExpr` — named +the phantom default, and the caller was dropped on a dangling target. The +clone-name agreement contract (#772) is what makes the pair, not the single +function, the unit of repair. + +The join property under test is order-invariance: which branch is written first +must not change the answer. Every witness below therefore comes with its +arm-swapped twin, and the pair must agree. +""" + +from __future__ import annotations + +import os +import tempfile + +import pytest + +from vera.checker import typecheck_with_artifacts +from vera.codegen import compile as codegen_compile +from vera.codegen import execute +from vera.codegen.api import CompileResult +from vera.parser import parse_to_ast + + +# --------------------------------------------------------------------- +# Sources +# --------------------------------------------------------------------- + +# The array-literal witness: the FIRST element is an `if` whose `then` branch +# throws. `_infer_array_element_type` -> `_infer_vera_type` named nothing, and +# `_translate_array_lit` raised `CodegenSkip`. `%s` selects which branch +# carries the throw, so the arm-swapped twin is the same program written the +# other way round. +_ARRAY_IF = """\ +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + handle[Exn] { + throw(@Bool) -> { + 0 + } + } in { + let @Array = [if %s then { throw(true) } else { 42 }, 7]; + @Array.0[1] + } +} +""" + +# The `match` spelling of the same element position: arm 0 throws. +_ARRAY_MATCH = """\ +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + handle[Exn] { + throw(@Bool) -> { + 0 + } + } in { + let @Array = [match Some(3) { %s }, 7]; + @Array.0[1] + } +} +""" + +# The pair-representation element (#841/#1045 width class): a `String` element +# is an i32 pair, so the drop is not specific to a scalar element width. +_ARRAY_STRING = """\ +public fn main(@Unit -> @String) + requires(true) + ensures(true) + effects(pure) +{ + handle[Exn] { + throw(@Bool) -> { + "caught" + } + } in { + let @Array = [if %s then { throw(true) } else { "aa" }, "bb"]; + @Array.0[1] + } +} +""" + +_GENERIC_PRELUDE = """\ +private forall fn idg(@T -> @T) + requires(true) + ensures(true) + effects(pure) +{ + @T.0 +} + +""" + +# The instantiation witness: `T` is fixed by an argument whose `then` branch +# throws. Discovery named the phantom-var default and the module failed to +# load. +_GENERIC_IF = _GENERIC_PRELUDE + """\ +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + handle[Exn] { + throw(@Bool) -> { + 0 + } + } in { + idg(if %s then { throw(true) } else { 42 }) + } +} +""" + +# The `match` twin of the instantiation witness. +_GENERIC_MATCH = _GENERIC_PRELUDE + """\ +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + handle[Exn] { + throw(@Bool) -> { + 0 + } + } in { + idg(match Some(3) { %s }) + } +} +""" + +# The consultor-agreement witness: EVERY arm completes, so nothing diverges — +# the failure was purely the missing discovery-side `MatchExpr` arm against the +# rewrite's arm-0 read. Kept separate from the divergence witnesses because it +# fails for the other reason, and a fix to one consultor alone leaves it red. +_GENERIC_MATCH_TOTAL = _GENERIC_PRELUDE + """\ +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + idg(match Some(3) { %s }) +} +""" + +# A constructor FIELD behind the same conditional: `_get_arg_type_info_wasm` +# reads the field through `_infer_vera_type`, so `Box` bound nothing and the +# unboxing clone was named for the wrong `T`. +_CTOR_FIELD = """\ +public data Box { + MkBox(T) +} + +private forall fn unbox(@Box -> @T) + requires(true) + ensures(true) + effects(pure) +{ + match @Box.0 { + MkBox(@T) -> @T.0 + } +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + handle[Exn] { + throw(@Bool) -> { + 0 + } + } in { + unbox(MkBox(if %s then { throw(true) } else { 42 })) + } +} +""" + +# `%s` fillers. For `if`, the condition selects the branch statically written +# first; the swapped twin negates it and exchanges the branch bodies, so the +# THROWING branch moves from `then` to `else`. For `match`, the arms are +# reordered. Either way the program means the same thing and must answer the +# same value. +_IF_DIVERGENT_FIRST = "false" +_IF_COMPLETING_FIRST = "true" + +_MATCH_DIVERGENT_FIRST = "None -> throw(true), Some(@Int) -> @Int.0" +_MATCH_COMPLETING_FIRST = "Some(@Int) -> @Int.0, None -> throw(true)" + +_MATCH_TOTAL_DIVERGENT_FIRST = "None -> 0, Some(@Int) -> @Int.0" +_MATCH_TOTAL_COMPLETING_FIRST = "Some(@Int) -> @Int.0, None -> 0" + + +def _if_swapped(template: str) -> str: + """The `if` witness with the throwing branch moved to `else`. + + The bodies are exchanged along with the condition, so the same branch + still runs — only its written position changes. + """ + return (template % _IF_COMPLETING_FIRST).replace( + "then { throw(true) } else { 42 }", "then { 42 } else { throw(true) }", + ).replace( + 'then { throw(true) } else { "aa" }', + 'then { "aa" } else { throw(true) }', + ) + + +# (label, divergent-first source, arm-swapped source, expected value) +_WITNESSES: list[tuple[str, str, str, object]] = [ + ( + "array_lit_if", + _ARRAY_IF % _IF_DIVERGENT_FIRST, + _if_swapped(_ARRAY_IF), + 7, + ), + ( + "array_lit_match", + _ARRAY_MATCH % _MATCH_DIVERGENT_FIRST, + _ARRAY_MATCH % _MATCH_COMPLETING_FIRST, + 7, + ), + ( + "array_lit_string_element", + _ARRAY_STRING % _IF_DIVERGENT_FIRST, + _if_swapped(_ARRAY_STRING), + "bb", + ), + ( + "generic_arg_if", + _GENERIC_IF % _IF_DIVERGENT_FIRST, + _if_swapped(_GENERIC_IF), + 42, + ), + ( + "generic_arg_match", + _GENERIC_MATCH % _MATCH_DIVERGENT_FIRST, + _GENERIC_MATCH % _MATCH_COMPLETING_FIRST, + 3, + ), + ( + "generic_arg_match_total", + _GENERIC_MATCH_TOTAL % _MATCH_TOTAL_DIVERGENT_FIRST, + _GENERIC_MATCH_TOTAL % _MATCH_TOTAL_COMPLETING_FIRST, + 3, + ), + ( + "constructor_field_if", + _CTOR_FIELD % _IF_DIVERGENT_FIRST, + _if_swapped(_CTOR_FIELD), + 42, + ), +] + + +# --------------------------------------------------------------------- +# Pipeline +# --------------------------------------------------------------------- + + +def _compile(source: str) -> CompileResult: + """Parse, typecheck, and compile — the `vera run` pipeline. + + Monomorphization consumes the checker's artifacts, so the plain + parse-and-compile shortcut would not exercise the clone-naming path these + witnesses turn on. + """ + with tempfile.NamedTemporaryFile( + mode="w", suffix=".vera", delete=False, encoding="utf-8", + ) as f: + f.write(source) + f.flush() + path = f.name + try: + program = parse_to_ast(source) + diags, arts = typecheck_with_artifacts( + program, source, file=path, collect_module_artifacts=True, + ) + errors = [d.description for d in diags if d.severity == "error"] + assert not errors, f"typecheck errors: {errors}" + return codegen_compile( + program, source=source, file=path, + expr_semantic_types=arts.expr_semantic_types, + expr_target_types=arts.expr_target_types, + module_artifacts=arts.module_artifacts, + ) + finally: + os.unlink(path) + + +def _run(source: str) -> object: + """Compile and execute `main`, asserting it survived codegen. + + The drop symptom is quiet at the value level — a skipped function simply + is not exported — so the export is asserted before the call, and the skip + NOTE is asserted separately below. + """ + result = _compile(source) + assert "main" in result.exports, ( + "check-green source lost `main` from the compiled exports; notes: " + + "; ".join(d.description for d in result.diagnostics) + ) + return execute(result, fn_name="main").value + + +# --------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("label", "source", "expected"), + [ + pytest.param(lbl, src, exp, id=lbl) + for lbl, src, _swapped, exp in _WITNESSES + ], +) +def test_divergent_first_branch_still_names_the_type( + label: str, source: str, expected: object, +) -> None: + """The witness: a branch that names nothing must not decide the answer.""" + assert _run(source) == expected + + +@pytest.mark.parametrize( + ("label", "source", "swapped", "expected"), + [pytest.param(*row, id=row[0]) for row in _WITNESSES], +) +def test_the_answer_does_not_depend_on_branch_order( + label: str, source: str, swapped: str, expected: object, +) -> None: + """The join property: writing the branches the other way round is the same + program, so the two spellings must compile to the same answer. + + This is the differential the fix is pinned by — it compares the two sides + of the invariant rather than asserting one remembered value, so it stays + meaningful if the expected value itself is ever renegotiated. + """ + divergent_first = _run(source) + completing_first = _run(swapped) + assert divergent_first == completing_first, ( + f"{label}: branch order changed the answer " + f"({divergent_first!r} vs {completing_first!r})" + ) + assert completing_first == expected + + +def test_no_codegen_skip_note_on_the_array_witness() -> None: + """The absence half: the drop announces itself as an [E602] note, and a + value assertion alone cannot see it once the function is gone.""" + result = _compile(_ARRAY_IF % _IF_DIVERGENT_FIRST) + skips = [ + d.description for d in result.diagnostics + if "could not infer array literal element type" in d.description + ] + assert not skips, f"array literal skipped despite a typed branch: {skips}" + + +@pytest.mark.parametrize( + ("label", "source"), + [ + pytest.param("if", _GENERIC_IF % _IF_DIVERGENT_FIRST, id="if"), + pytest.param( + "match", _GENERIC_MATCH % _MATCH_DIVERGENT_FIRST, id="match", + ), + pytest.param( + "match_total", + _GENERIC_MATCH_TOTAL % _MATCH_TOTAL_DIVERGENT_FIRST, + id="match_total", + ), + ], +) +def test_the_clone_is_named_for_the_real_instantiation( + label: str, source: str, +) -> None: + """The positional half: the value could be right for the wrong reason, so + pin WHICH clone the module carries. + + `idg$Bool` is the phantom-var default — the name discovery falls to when + the argument binds nothing — and it is an i32 clone reached with an i64 + argument. Asserting its absence is what distinguishes "the type was + inferred" from "the default happened to work". + """ + wat = _compile(source).wat + assert "$idg$Int" in wat, f"{label}: no Int clone in the module:\n{wat}" + assert "$idg$Bool" not in wat, ( + f"{label}: the phantom-var default was instantiated instead of the " + f"argument's real type:\n{wat}" + ) diff --git a/tests/test_walker_defensive_branches_597.py b/tests/test_walker_defensive_branches_597.py index d1348cbc..6489b323 100644 --- a/tests/test_walker_defensive_branches_597.py +++ b/tests/test_walker_defensive_branches_597.py @@ -250,7 +250,14 @@ class TestInferVeraTypeDefensiveBranches: """Block / MatchExpr / HandleExpr → trailing-expr type; AssertExpr / AssumeExpr → "Unit"; AnonFn / QualifiedCall / ModuleCall → None (path/qualifier fields can't be threaded - through the bare-name FnCall dispatcher).""" + through the bare-name FnCall dispatcher). + + Since #1286 the `MatchExpr` arm joins over the arms rather than + reading `arms[0]` — it answers from the first arm that YIELDS a + name, an arm that only throws naming none. The single-arm cases + below are unaffected by that distinction; the join itself is pinned + in `test_infer_vera_type_join_1286.py`. + """ def test_block_returns_trailing_expr_type(self) -> None: ctx = _make_ctx() diff --git a/vera/monomorphize.py b/vera/monomorphize.py index a98b1dcf..8bcb6bb4 100644 --- a/vera/monomorphize.py +++ b/vera/monomorphize.py @@ -2534,8 +2534,31 @@ def _infer_vera_type_name( return self._infer_vera_type_name( expr.operand, ctor_to_adt, generic_decls) if isinstance(expr, ast.IfExpr): - return self._infer_vera_type_name( + # #1286: the first branch that yields a name, mirroring the + # WASM call-rewrite twin (`InferenceMixin._infer_vera_type`, + # vera/wasm/inference.py) arm for arm. The two consultors must + # land on the SAME name or the discovered clone dangles at the + # call the rewrite emits (E602) — so the join lands on both + # sides together, exactly as the #1276 WAT deciders did. + then_vt = self._infer_vera_type_name( expr.then_branch.expr, ctor_to_adt, generic_decls) + if then_vt is not None: + return then_vt + return self._infer_vera_type_name( + expr.else_branch.expr, ctor_to_adt, generic_decls) + if isinstance(expr, ast.MatchExpr): + # #1286: discovery had NO `MatchExpr` arm at all, so a `match` + # argument answered `None` and the instantiation fell to the + # phantom-var default while the rewrite named it from arm 0 — + # a dangling `idg$Int` that dropped the caller (E602) on + # check-green source, even with every arm completing. The join + # closes the gap and the desync in one arm. + for arm in expr.arms: + arm_vt = self._infer_vera_type_name( + arm.body, ctor_to_adt, generic_decls) + if arm_vt is not None: + return arm_vt + return None if isinstance(expr, ast.StringLit): return "String" if isinstance(expr, ast.InterpolatedString): diff --git a/vera/wasm/inference.py b/vera/wasm/inference.py index b082f1ed..e5986351 100644 --- a/vera/wasm/inference.py +++ b/vera/wasm/inference.py @@ -966,9 +966,12 @@ def _infer_vera_type(self, expr: ast.Expr) -> str | None: # else from `_infer_fncall_vera_type` # ArrayLit → "Array" # IndexExpr → element type - # IfExpr → from then-branch + # IfExpr → from the first branch that yields a name + # (#1286 — the Vera-level twin of #1276's + # WAT join; a diverging `then` names nothing) # Block → from trailing expr (defensive add #597) - # MatchExpr → from first arm body (defensive add #597) + # MatchExpr → from the first arm body that yields a name + # (defensive add #597; #1286 join) # HandleExpr → from body (defensive add #597) # AssertExpr → "Unit" (defensive add #597) # AssumeExpr → "Unit" (defensive add #597) @@ -1057,9 +1060,22 @@ def _infer_vera_type(self, expr: ast.Expr) -> str | None: elem = self._infer_index_element_type(expr) return elem if isinstance(expr, ast.IfExpr): - if expr.then_branch.expr is not None: - return self._infer_vera_type(expr.then_branch.expr) - return None # pragma: no cover + # #1286: the FIRST branch that yields a name, not the `then` + # branch alone — the Vera-level twin of the #1276 WAT join + # (`_infer_expr_wasm_type` / `_infer_block_result_type`). A + # `then` whose every path throws names no type, and answering + # `None` for the whole `if` on that basis lost the type the + # completing branch carries: as an array-literal element the + # literal was dropped with the loud [E602] skip, and as a + # generic argument the instantiation fell to the phantom-var + # default (`idg$Bool` for an `Int` argument) and the module + # failed to load — both from check- and verify-green source. + # Branches that DO complete must agree on their type (the + # checker enforces that), so the first answer is the answer. + then_vt = self._infer_vera_type(expr.then_branch.expr) + if then_vt is not None: + return then_vt + return self._infer_vera_type(expr.else_branch.expr) # Defensive adds (#597) — these compound expressions could # flow in here from generic-arg inference paths, but today # most callers preprocess first. Returning the right Vera @@ -1069,8 +1085,12 @@ def _infer_vera_type(self, expr: ast.Expr) -> str | None: if isinstance(expr, ast.Block): return self._infer_vera_type(expr.expr) if isinstance(expr, ast.MatchExpr): - if expr.arms: - return self._infer_vera_type(expr.arms[0].body) + # #1286: the first arm that yields a name — see the `IfExpr` + # arm above, same shape and same two symptoms. + for arm in expr.arms: + arm_vt = self._infer_vera_type(arm.body) + if arm_vt is not None: + return arm_vt return None # HandleExpr.body is non-Optional Block; its .expr is also # non-Optional (vera/ast.py:481, 470). From 94202d9ba8283bd310f3c0cb4c1daf4db0ff6031 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 19:23:30 +0100 Subject: [PATCH 51/69] Close the review round: the same gap in two more shapes, and exact WAT matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `MatchExpr` arm this PR added to the discovery consultor recursed into `arm.body` — and the transformer leaves a braced match-arm body AS an `ast.Block`, which the discovery consultor had no arm for while the rewrite twin does. So `Some(@Int) -> { let @Int = @Int.0 + 1; @Int.0 }` named nothing on the discovery side and `idg$Int` on the rewrite side: the c5 dangling-clone divergence one shape over, on check-green source that loses `main` to E602. It is masked whenever a later arm yields — the join falls through and recovers the same type by luck of agreement — so the witness pairs the block-bodied arm with a throwing one. The sweep it prompted found the `if` spelling (a branch whose TAIL is itself braced; a `let` inside the branch is a statement, so the existing arms already land on the tail) and a third shape, a `handle` in argument position, likewise named from its body by the rewrite and by nothing here. All three are one gap: the two consultors must stay structurally parallel arm for arm, which is this PR's core claim. Both new arms are exact mirrors of their twins. An `IndexExpr` argument is measured to dangle identically and is deliberately left out: the rewrite's arm delegates to `_infer_index_element_type`, which resolves chained indexing, aliases and `Future` payloads against codegen tables the monomorphizer does not have, so a partial mirror would replace a shape where both consultors answer "unknown" with one where they disagree — the worse failure. Recorded in the test module, TESTING.md and the CHANGELOG. The WAT assertions move from `"$idg$Int" in wat` to the shared `wat_fn_names` / `wat_calls` helpers. The substring form is a prefix test: a module defining only `$idg$IntAlias` satisfies it, a false PASS in precisely the case the assertion exists to catch — one clone impersonating another. Measured both ways on a fabricated module before and after. The local full-pipeline `_compile` stays. `_check_ok` and `_verify_ok` return `None` — they assert and discard — while every assertion here reads an artefact of the compile, and `codegen_helpers._compile` reaches a `CompileResult` without typechecking, so it supplies none of the artifacts monomorphization reads; adopting it would quietly weaken the test. 21 test files define a local full-pipeline `_compile`, this issue's direct sibling `test_handle_exn_divergent_result_1276.py` among them. The rationale is written into the helper's docstring. Two witnesses added with their arm-swapped twins, a presence cell for the handle shape, and three more clone-naming cells: 18 tests to 26. Mutation cells now six, each reverted alone: 13 / 8 / 8 / 9 / 6 / 2 failures, all six at once fails all 26. Conformance 239/239, and the corpus differential re-measured against this base is still 0 movers over 287 programs. Co-Authored-By: Claude --- CHANGELOG.md | 2 +- TESTING.md | 2 +- tests/test_infer_vera_type_join_1286.py | 161 +++++++++++++++++++++++- vera/monomorphize.py | 22 ++++ 4 files changed, 182 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1d08ace..51d8d201 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **The `ch09_json` conformance entry cites the section that exists.** Its manifest `spec_ref` read `Section 9.4.4`, a section Chapter 9 does not have — the chapter's `9.4` runs to `9.4.3 Map` and `Json` is `9.7.1`. -- **The Vera-level type namers join over a conditional instead of reading one branch** ([#1286](https://github.com/aallan/vera/issues/1286)). [#1276](https://github.com/aallan/vera/issues/1276) fixed the WAT result-type deciders to take the first branch that yields a type; their Vera-level siblings kept the one-branch read — `InferenceMixin._infer_vera_type` (the WASM call-rewrite consultor) read `then_branch` only and `arms[0]` only, and `Monomorphizer._infer_vera_type_name` (the instantiation-discovery consultor) read `then_branch` only and had no `MatchExpr` arm at all. A branch whose every path `throw`s names no type, so reading only that branch answered "unknown" for the whole expression, and the issue's latency estimate was wrong in the program's favour: the shape is constructible, and it is loud in two different ways from check-green source. As an **array-literal element** (`[if false then { throw(true) } else { 42 }, 7]`, and the `match` and `String` spellings of the same position) the unknown element type raised `CodegenSkip`, so a declared `public fn main` — `vera check`-green and `vera verify`-green at 2 Tier 1 — was absent from the compiled exports behind an `[E602]` note. As a **generic argument** (`idg(if false then { throw(true) } else { 42 })`, verify-green at 4 Tier 1) the type variable bound nothing and the clone fell to the phantom-var default: the module carried `idg$Bool`, an i32 clone, reached with an i64 argument, and failed to load with `Invalid input WebAssembly code at offset 73: type mismatch: expected i32, found i64`. The same reading through a constructor **field** mis-instantiated the unboxing clone the other way round (`expected i64, found i32`). The repair lands on both consultors together because the `match` case was broken in both directions: with every arm completing and nothing diverging, the rewrite named `idg$Int` from arm 0 while discovery, having no arm for `MatchExpr`, named the phantom default, and the caller was dropped on a dangling target — the clone-name agreement contract ([#772](https://github.com/aallan/vera/issues/772)) makes the pair, not either function, the unit of repair. All 287 pre-existing corpus programs (`examples/` plus `tests/conformance/`, recursive) emit byte-identical WAT, since the join only changes an answer that was previously unknown; `ch02_generic_arg_branch_join` promotes the witness into the conformance suite at level `run`. +- **The Vera-level type namers join over a conditional instead of reading one branch** ([#1286](https://github.com/aallan/vera/issues/1286)). [#1276](https://github.com/aallan/vera/issues/1276) fixed the WAT result-type deciders to take the first branch that yields a type; their Vera-level siblings kept the one-branch read — `InferenceMixin._infer_vera_type` (the WASM call-rewrite consultor) read `then_branch` only and `arms[0]` only, and `Monomorphizer._infer_vera_type_name` (the instantiation-discovery consultor) read `then_branch` only and had no `MatchExpr` arm at all. A branch whose every path `throw`s names no type, so reading only that branch answered "unknown" for the whole expression, and the issue's latency estimate was wrong in the program's favour: the shape is constructible, and it is loud in two different ways from check-green source. As an **array-literal element** (`[if false then { throw(true) } else { 42 }, 7]`, and the `match` and `String` spellings of the same position) the unknown element type raised `CodegenSkip`, so a declared `public fn main` — `vera check`-green and `vera verify`-green at 2 Tier 1 — was absent from the compiled exports behind an `[E602]` note. As a **generic argument** (`idg(if false then { throw(true) } else { 42 })`, verify-green at 4 Tier 1) the type variable bound nothing and the clone fell to the phantom-var default: the module carried `idg$Bool`, an i32 clone, reached with an i64 argument, and failed to load with `Invalid input WebAssembly code at offset 73: type mismatch: expected i32, found i64`. The same reading through a constructor **field** mis-instantiated the unboxing clone the other way round (`expected i64, found i32`). The repair lands on both consultors together because the `match` case was broken in both directions: with every arm completing and nothing diverging, the rewrite named `idg$Int` from arm 0 while discovery, having no arm for `MatchExpr`, named the phantom default, and the caller was dropped on a dangling target — the clone-name agreement contract ([#772](https://github.com/aallan/vera/issues/772)) makes the pair, not either function, the unit of repair. All 287 pre-existing corpus programs (`examples/` plus `tests/conformance/`, recursive) emit byte-identical WAT, since the join only changes an answer that was previously unknown; `ch02_generic_arg_branch_join` promotes the witness into the conformance suite at level `run`. The review round closed the same divergence in two further shapes, both of them the one gap — the discovery consultor must stay structurally parallel to the rewrite one, arm for arm. It had no `Block` arm, and the transformer leaves a braced match-arm body AS a `Block`, so `idg(match … { Some(@Int) -> { let @Int = @Int.0 + 1; @Int.0 }, None -> throw(true) })` named nothing on the discovery side and `idg$Int` on the rewrite side: a dangling target that dropped `main` from check-green source. A braced `if` branch whose tail is itself braced does the same, and so does a `handle` in argument position, which likewise had no arm. An `IndexExpr` argument is measured to dangle identically and is deliberately left for its own change: the rewrite's arm resolves chained indexing, aliases and `Future` payloads against codegen tables the monomorphizer does not have, so a partial mirror would replace a shape where both consultors answer "unknown" with one where they disagree. - **A user-defined `fn get` / `fn put` is no longer hijacked by an enclosing handler** ([#1284](https://github.com/aallan/vera/issues/1284)). Three sites answered "does this `get` mean the user's declaration or the effect operation?" independently. The checker answers user-fn-first — `_check_call_with_args` looks a bare name up as a function before it looks it up as an operation, so a declaration named `get` owns every bare `get(...)` in its scope, which an arity or argument-type error at such a call site proves by reporting the *user's* signature (E201/E202). Codegen answered twice more: the declared-effect row in `vera/codegen/functions.py` withheld the intrinsic when `_fn_sigs` already owned the name, and the handler expression in `vera/wasm/calls_handlers.py` installed `get`/`put` unconditionally. From `vera check`-green source that produced, depending on the nesting shape, a **silently wrong value** (`nat_to_int(get(3))` under `handle[State](@Int = 5)` returned the cell's 5 for the function's 4, and the argument was not even emitted), a **module WASM validation rejects** (a `@Bool`-returning user `get` took `state_get_Int`'s i64 into an `i32` position; different-family nesting took the *enclosing* cell's getter at the wrong width), or a **spurious `[E602]`** in which the #1233 unaddressable-cell gate refused `main` outright, naming "a bare or qualified State operation `get`" the program never contained. The repair is one predicate, `vera.slots.bare_call_denotes_user_fn`, stating the checker's rule once and consumed by the bare-call dispatch in `vera/wasm/calls.py` (which now gates the clause-inline registry, the host-cell intrinsics and the addressability gate together), by the three bare-`FnCall` result-type inference sites, and by the monomorphizer's discovery walk — each passing its own name table, so the sites cannot answer differently about the table they share. Gating the *dispatch* rather than the *registries* is what makes it correct rather than merely consistent: the registries record which cell an op name reaches, which is true whatever the program's declarations are called, and withholding an entry answered both questions with one table. That is why the gate-only fix measured during PR #1283's review turned the loud skip into a differently-broken module, and why it also cost the qualified spelling its cell — `State.put(5)` in a function that also declares `fn put` compiled to `call $vera.put` and failed to link, which now lowers to the intrinsic the checker always meant. Discovery's `MonoContext.fn_names` moves to the same lookup-time question, so a `get(())` fixing a generic's type variable under a handler names the clone the rewrite emits. All 256 conformance and example programs emit byte-identical WAT. **The `W002` async-commutativity warning was the same defect in the checker's own file** and is corrected with them: `_collect_expr_effects` asked `lookup_effect_op` before the scoped function lookup — the last op-first consumer — so a user function named after an operation contributed the *operation's* parent effect to the commutativity analysis instead of its own declared row, wrong in both directions. A **pure** `fn get`, in a program containing no `State` at all, drew `async argument performs State effects`; a `fn get` that performs `IO`, under a row naming `Http` first, drew **no warning**, because the walk bound the name to `Http.get`, which is inside the commutative whitelist, and silently withheld the eager-evaluation warning the program is owed. Both are pinned with rename controls — the byte-identical program with the helper called `gett` / `fetch` was correct throughout — beside a control that an unshadowed bare `get(())` under a `State` row still warns, so the fix cannot degenerate into never reporting `State`. The walk's comment claiming it resolves "like the call checker above" is now true. One caveat the predicate did not close on its own: codegen's name table was not scope-accurate, so a name the call site cannot see still answered "user-owned" there — a property of the table rather than of the rule, closed by [#1299](https://github.com/aallan/vera/issues/1299) below. diff --git a/TESTING.md b/TESTING.md index 43786c0f..151011fb 100644 --- a/TESTING.md +++ b/TESTING.md @@ -93,7 +93,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_lexical_fn_scope_1299.py` | 56 | 1,554 | #1299 — codegen's bare-call ownership table must be the CALL SITE's lexical scope. The #1284 predicate is one rule over two tables, and codegen's was `set(_fn_sigs)`: every symbol the whole compilation absorbed, including names the compiling body cannot see, so a bare `get(())` the checker resolved to a `State` operation was lowered as a call to some other declaration. Four routes, all check-green — an imported module's **private** `get`, a **public** one a selective import excludes, a `where` helper of a **`forall` parent** (which keeps a bare key beside its clone-qualified one where a non-generic parent's does not), and the ability operation `show`, which E151 does not reserve and which reaches the same table through the INTRINSIC gate rather than the op one. Where the widths agreed the module loaded and answered the invisible declaration's value (7007 for the cell's 42007); where they differed it failed to load; the generic-`where` route is always loud (`unknown func $get`). Every expected value is the checker's, PROVEN by a type oracle rather than assumed — the invisible `get` returns `@Bool` while the caller returns `@Int` from it and checks green — and each route carries a rename control. The visibility matrix (public/private × in-filter/excluded/wildcard × shadowed/unshadowed × direct/transitive) asserts the verify verdict and the runtime value together per cell. The two directions are pinned at once: the sibling loses the name, the generic TEMPLATE keeps it (asserted on the emitted instruction stream, since monomorphization supersedes the template and a value assertion would be green either way), and a lifted closure — compiled through its own `WasmContext` — inherits its parent's scope. Four table invariants sit beside them: the scoped set is a subset of the registry, every `$`-bearing key stays in it, prelude names stay in it, and every emission door supplies a declaration its own helpers | | `test_phantom_generic_instances_1271.py` | 14 | 358 | #1271 — discovery inside a still-generic scope must not instantiate a callee at an ENCLOSING scope's type VARIABLE. `pick(@U.1, @U.0)` inside `forall fn helper` bound `pick`'s variable to the NAME `U`, so a `pick$U` clone was emitted whose parameter has no WASM type and which the compilability pass then skipped with a loud `[E604]` — the noise that kept #1223's shapes out of the conformance suite. Drives the four #1223 fixtures plus a mutual-recursion shape (two sibling generic helpers under a generic parent, whose phantoms include one arriving through a callee's declared RETURN type, `leaf$W`), asserting on ONE compile that no clone is keyed by a type variable, that no E602/E604/E605 skip is emitted, AND that the genuinely concrete clone is still there — the third assertion being what separates the filter from an over-filter that would take the real instantiation with it. Plus the **primitive-spelled binder** matrix (`forall`, ``, ``, ``), each row instantiating a sibling at exactly the type its binder is spelled like — a shared `idw(5)` would have let every row but `Int` pass for free — asserted on the clone set AND on the program still running; with the `Q`-binder control that keeps a genuine type variable filtered, so the fix cannot degenerate into "never filter". That control CREATES a live phantom candidate — a generic helper under a generic parent, handing its callee an argument typed by its own binder — because a control that merely fails to create one holds under any filter including none; mutation-checked by disabling the filter, which turns it red | | `test_handle_exn_divergent_result_1276.py` | 10 | 391 | #1276 — a `handle[Exn]` whose clause body AND handled body both diverge emitted a result-LESS `block` into a result-expecting context: check-green, verify-green, rejected at load with `type mismatch: expected i64 but nothing on stack`. Four divergent shapes (the issue's Int rethrow, the `Byte` payload spelling #1269 unmasked, a three-deep rethrow chain, and a clause diverging through both arms of an `if`), each asserted on valid WASM AND on the observable — the OUTER handler's clause value, 1000. Paired with the Unit TWIN, which infers `None` for the same reason but DOES complete: it must keep running and its WAT must contain no `unreachable` at all. The pairing is the point — `result_wt is None` means two things wanting opposite lowerings, and a fix that terminated both would trap a program that runs. The MIRROR family covers a clause that throws on one path and COMPLETES on the other (`if` and `match` spellings), where the inference read only the `then` branch / arm 0, answered `None`, and left the completing path's value stranded in a result-less block; the `if` case appears twice with different thrown values so both the throwing and the completing path are exercised from one inference | -| `test_infer_vera_type_join_1286.py` | 18 | 421 | #1286 — the VERA-level siblings of #1276's WAT join. `InferenceMixin._infer_vera_type` (the WASM call-rewrite consultor) read `then_branch` only and `arms[0]` only, and `Monomorphizer._infer_vera_type_name` (the instantiation-discovery consultor) read `then_branch` only and had no `MatchExpr` arm at all — so a branch that throws, naming no type, decided the answer for the whole expression. Two symptoms from check-green (and, with contracts, verify-green) source: as an array-literal ELEMENT the `None` raised `CodegenSkip` and the declared `main` simply left the exports with a loud [E602] note, and as a GENERIC ARGUMENT it left the type variable unbound, so `idg$Bool` — the phantom-var default, an i32 clone — was emitted for an i64 `Int` argument and the module failed to load. Seven witnesses (array literal in the `if`, `match` and pair-representation `String` spellings; generic argument in the `if` and `match` spellings; the constructor FIELD behind the same conditional), each asserted on the value, on `main` surviving into the exports, and on the absence of the skip note — the drop is quiet at the value level once the function is gone. The seventh witness is the consultor-AGREEMENT case, where every arm completes and nothing diverges: the rewrite named `idg$Int` from arm 0 while discovery named the phantom default, and the caller was dropped on a dangling target — which is why the repair lands on both consultors together, the clone-name agreement contract (#772) making the pair the unit. Each witness carries its ARM-SWAPPED twin and the pair must agree, so the join property under test is order-invariance rather than a remembered value; a WAT assertion pins WHICH clone the module carries, since a value can be right for the wrong reason. Mutation-checked one edit at a time: reverting the rewrite-side `if` fails 9, its `match` 4, the discovery-side `if` 5, the discovery-side `match` 6, and all four at once fails all 18 | +| `test_infer_vera_type_join_1286.py` | 26 | 576 | #1286 — the VERA-level siblings of #1276's WAT join. `InferenceMixin._infer_vera_type` (the WASM call-rewrite consultor) read `then_branch` only and `arms[0]` only, and `Monomorphizer._infer_vera_type_name` (the instantiation-discovery consultor) read `then_branch` only and had no `MatchExpr` arm at all — so a branch that throws, naming no type, decided the answer for the whole expression. Two symptoms from check-green (and, with contracts, verify-green) source: as an array-literal ELEMENT the `None` raised `CodegenSkip` and the declared `main` simply left the exports with a loud [E602] note, and as a GENERIC ARGUMENT it left the type variable unbound, so `idg$Bool` — the phantom-var default, an i32 clone — was emitted for an i64 `Int` argument and the module failed to load. Seven witnesses (array literal in the `if`, `match` and pair-representation `String` spellings; generic argument in the `if` and `match` spellings; the constructor FIELD behind the same conditional), each asserted on the value, on `main` surviving into the exports, and on the absence of the skip note — the drop is quiet at the value level once the function is gone. The seventh witness is the consultor-AGREEMENT case, where every arm completes and nothing diverges: the rewrite named `idg$Int` from arm 0 while discovery named the phantom default, and the caller was dropped on a dangling target — which is why the repair lands on both consultors together, the clone-name agreement contract (#772) making the pair the unit. Each witness carries its ARM-SWAPPED twin and the pair must agree, so the join property under test is order-invariance rather than a remembered value; a WAT assertion pins WHICH clone the module carries, since a value can be right for the wrong reason. The PR review round found the same divergence one shape over and the sweep it prompted found a third, both closed here: discovery had no `Block` arm, and the transformer leaves a braced match-arm body AS a `Block`, so `Some(@Int) -> { let … }` named nothing there while the rewrite named the concrete clone — `idg$Int` emitted and never registered, `main` dropped from a check-green program. It only reaches a wrong answer when no later arm yields either, so the witness pairs the block-bodied arm with a throwing one; the braced-`if` variant needs the branch TAIL to be a block in its own right, a `let` inside the branch being a statement. The third is a `handle` in argument position, a presence cell since it has no branches to exchange. An `IndexExpr` argument dangles the same way and is deliberately NOT closed here — the rewrite's arm resolves chained indexing, aliases and `Future` payloads against codegen tables the monomorphizer lacks, so a partial mirror would trade "both say unknown" for "the two disagree". WAT membership is tested through `wat_fn_names` / `wat_calls`, not `in wat`: the substring form is a prefix test that a longer mangled symbol satisfies, which is exactly how one clone impersonates another. Mutation-checked one edit at a time: reverting the rewrite-side `if` fails 13, its `match` 8, the discovery-side `if` 8, `match` 9, `Block` 6 and `HandleExpr` 2, and all six at once fails all 26 | | `test_generic_under_generic_callees_1223.py` | 8 | 298 | #1223 — a generic `where`-helper under a GENERIC parent instantiates its own generic callees. The helper is monomorphized only during clone hoisting, outside the worklist that rescans every clone it emits, so a top-level generic called from the helper body was discovered only in its still-generic spelling (`pick$U`, binding the enclosing type variable's NAME) while the rewrite called `pick$Bool` — E602 skip, E620 drop of the parent and of `main`, "No exported functions" from a check-clean, verify-clean program. Four shapes — a user generic, the prelude twin (`option_unwrap_or`), two levels of generic nesting where the INNER helper is the caller, and the non-generic-parent control that compiled before the fix and must keep compiling (it is what proves the trigger is the generic ancestor rather than the nested helper) — each asserted on no E602/E620, the checker's run value (the helper's argument order is non-commutative, so a miswiring gives 7 instead of 3), and a REGISTERED-vs-RESOLVED differential. That differential captures the emitted mono-decl names rather than `_emitted_instances` (whose generic-under-generic entries are keyed by the concrete-free lexical chain, not by the per-clone emission name the rewrite calls) and captures the rewrite side on `_resolve_generic_call` rather than from the WAT, because a desync skips the calling function and removes the dangling `call` along with it. The verifier's half of the pair is pinned in `test_monomorphize_differential.py`'s inline corpus, not here | | `test_mono_effect_op_naming_1207.py` | 9 | 339 | #1207 — monomorphization discovery and the WASM call-rewrite name ONE clone when an effect operation fixes a generic's type argument. A differential over the two consultors, not a unit test on either: the compiler's own E602 ("call target not registered in this module") IS the two sides disagreeing, so each case asserts no E602/E620, and additionally pins WHICH name they agreed on — an alignment on the wrong one still fails. Four instantiation-driving shapes (`get(())` as an array-literal element under a plain `State` cell, under a `type Count = Nat` alias cell whose clone must be `pick$Count`, in a function whose operation comes from the DECLARED effect row rather than an enclosing `handle`, and in direct argument position), plus the `array_append` builtin-argument control and a shadowed-name control — a user `get(@Unit -> @Bool)` is NOT an effect op in a declared row, so the clone must be `pick$Bool` and not the cell's; that case is green before the fix as well as after, which is what makes it a guard against the alignment over-reaching rather than a second copy of the repro | | `test_effect_op_determinism.py` | 9 | 504 | #1215 — bare effect-op resolution order: the built-in `State` and `Http` both declare `get`, so `effects(, Http>)` is a two-candidate row with no user `effect` declaration needed. The two candidate bindings are made to produce DIFFERENT observables (the source-order program runs to `70`; the reversed row is a loud `E217` naming `Http.get`), swept across six `PYTHONHASHSEED` values in child interpreters so a frozenset-order flip cannot pass — plus the innermost-handler-beats-declared-row precedence case, a signature-level assertion that the resolved `OpInfo` follows the recorded order both ways (including the deterministic name tiebreak for a row member no order tuple mentions), and the qualified-lookup control. The type-ARGUMENT sibling rides here too: `effects(, State>)` (two independent cells, spec §7.3.3) had the identical frozenset dependence in `_effect_type_mapping`, and codegen took the LAST instantiation in the row where the checker takes the first — both now source-order-first, swept the same way. A sixth sweep covers the public `ordered_effect_row()` fallback for a row member no order tuple mentions: its two members share the effect NAME and differ only in type ARGUMENT, so a name-only sort key ties them and hands back frozenset order — the order AND the `_effect_type_mapping` selection it drives are both asserted stable across the same seeds. Two further sweeps take that structural key down a level: a type argument may itself be a FUNCTION type, whose own effect row was rendered by `pretty_effect` — so two outer instances differing only INSIDE a nested row (by a refinement's predicate, or by a type variable's built-in marker) tied again, and both legs are asserted single-outcome across the same seeds, separately, so a regression names the elision that came back | diff --git a/tests/test_infer_vera_type_join_1286.py b/tests/test_infer_vera_type_join_1286.py index d7f652c5..190f2eb7 100644 --- a/tests/test_infer_vera_type_join_1286.py +++ b/tests/test_infer_vera_type_join_1286.py @@ -36,6 +36,28 @@ The join property under test is order-invariance: which branch is written first must not change the answer. Every witness below therefore comes with its arm-swapped twin, and the pair must agree. + +The PR review round found the same divergence one shape over, and the sweep it +prompted found a third. Discovery had no `Block` arm, and the transformer +leaves a braced match-arm body AS a `Block` — so `Some(@Int) -> { let … }` named +nothing there while the rewrite, which HAS the arm, named the concrete clone. +It reaches a wrong answer only when no later arm yields either (a plain +`None -> 0` sibling recovers the type by luck of agreement), which is why the +witness pairs the block-bodied arm with a throwing one. The braced-`if` variant +needs the branch TAIL to be a block in its own right, a `let` inside the branch +being a statement rather than a nested block. The third shape is a `handle` in +argument position, likewise named from its body by the rewrite and by nothing on +the discovery side. All three are one gap: the two consultors must stay +structurally parallel, arm for arm, which is this fix's core claim. + +One shape found by the same sweep is NOT closed here and is deliberately left +loud: an `IndexExpr` argument (`idg(@Array.0[1])`) dangles the same way, +but the rewrite's arm delegates to `_infer_index_element_type`, which resolves +chained indexing, aliases and `Future` payloads against codegen tables the +monomorphizer does not have. A partial mirror would answer differently from the +rewrite for those cases — trading a shape where both consultors say "unknown" +for one where they disagree, which is the worse failure — so it wants its own +change rather than a line here. """ from __future__ import annotations @@ -45,6 +67,7 @@ import pytest +from tests.codegen_helpers import wat_calls, wat_fn_names from vera.checker import typecheck_with_artifacts from vera.codegen import compile as codegen_compile from vera.codegen import execute @@ -209,6 +232,69 @@ } """ +# The BLOCK family (PR review). The transformer leaves a braced match-arm body +# as an `ast.Block`, and the rewrite consultor has a `Block` arm while discovery +# did not — so a block-bodied arm named nothing on the discovery side and the +# concrete name on the rewrite side. It only reaches a wrong ANSWER when no +# later arm yields either: with a plain `None -> 0` beside it, the join fell +# through to that arm and recovered the same type by luck. So the throwing +# sibling is what makes the block-bodied arm the only one that can answer. +_GENERIC_MATCH_BLOCK_ARM = _GENERIC_PRELUDE + """\ +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + handle[Exn] { + throw(@Bool) -> { + 0 + } + } in { + idg(match Some(3) { %s }) + } +} +""" + +# The `if` spelling of the Block gap: a branch whose TAIL is itself braced. The +# `if` arms read `branch.expr`, which is already the trailing expression — a +# `let` inside the branch is a statement, not a nested block — so this needs the +# tail to be a block in its own right before the gap is reachable. +_GENERIC_IF_NESTED_BLOCK = _GENERIC_PRELUDE + """\ +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + handle[Exn] { + throw(@Bool) -> { + 0 + } + } in { + idg(if %s then { %s } else { %s }) + } +} +""" + +# The third shape found by the same sweep: a `handle` in argument position. The +# rewrite names it from its body's trailing expression and discovery named it +# from nothing. There are no branches to exchange, so this one is a presence +# cell rather than a swapped pair. +_GENERIC_HANDLE_ARG = _GENERIC_PRELUDE + """\ +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + idg(handle[Exn] { + throw(@Bool) -> { + 0 + } + } in { + 42 + }) +} +""" + # `%s` fillers. For `if`, the condition selects the branch statically written # first; the swapped twin negates it and exchanges the branch bodies, so the # THROWING branch moves from `then` to `else`. For `match`, the arms are @@ -217,6 +303,10 @@ _IF_DIVERGENT_FIRST = "false" _IF_COMPLETING_FIRST = "true" +_BLOCK_ARM = "Some(@Int) -> { let @Int = @Int.0 + 1; @Int.0 }" +_THROW_ARM = "None -> throw(true)" +_BLOCK_TAIL = "{ let @Int = 41 + 1; @Int.0 }" + _MATCH_DIVERGENT_FIRST = "None -> throw(true), Some(@Int) -> @Int.0" _MATCH_COMPLETING_FIRST = "Some(@Int) -> @Int.0, None -> throw(true)" @@ -270,6 +360,20 @@ def _if_swapped(template: str) -> str: _GENERIC_MATCH % _MATCH_COMPLETING_FIRST, 3, ), + ( + "generic_arg_match_block_arm", + _GENERIC_MATCH_BLOCK_ARM % f"{_THROW_ARM}, {_BLOCK_ARM}", + _GENERIC_MATCH_BLOCK_ARM % f"{_BLOCK_ARM}, {_THROW_ARM}", + 4, + ), + ( + "generic_arg_if_nested_block", + _GENERIC_IF_NESTED_BLOCK % ( + _IF_DIVERGENT_FIRST, "throw(true)", _BLOCK_TAIL), + _GENERIC_IF_NESTED_BLOCK % ( + _IF_COMPLETING_FIRST, _BLOCK_TAIL, "throw(true)"), + 42, + ), ( "generic_arg_match_total", _GENERIC_MATCH_TOTAL % _MATCH_TOTAL_DIVERGENT_FIRST, @@ -296,6 +400,20 @@ def _compile(source: str) -> CompileResult: Monomorphization consumes the checker's artifacts, so the plain parse-and-compile shortcut would not exercise the clone-naming path these witnesses turn on. + + Why not the shared `_check_ok` / `_verify_ok` (PR review): both return + `None` — they assert and discard — while every assertion here reads an + artefact of the compile (`result.exports`, `result.wat`, the executed + value), so they cannot serve without changing their return types across the + whole checker and verifier suite. `codegen_helpers._compile` returns a + `CompileResult` but reaches it by `parse_file` + `transform` + `compile` + with no typecheck, so it supplies none of the artifacts monomorphization + reads — using it would quietly weaken the test rather than share code. A + local full-pipeline `_compile` is the established shape for exactly this: + 21 test files define one, `test_handle_exn_divergent_result_1276.py` (this + issue's direct sibling) and `test_composite_postcondition_eq_912.py` among + them. The WAT assertions DO use the shared boundary-safe helpers, which is + the part of the suggestion that applies. """ with tempfile.NamedTemporaryFile( mode="w", suffix=".vera", delete=False, encoding="utf-8", @@ -388,6 +506,18 @@ def test_no_codegen_skip_note_on_the_array_witness() -> None: assert not skips, f"array literal skipped despite a typed branch: {skips}" +def test_a_handle_expression_in_argument_position_names_its_body() -> None: + """The third shape of the one gap (PR review sweep). + + `handle` reached the discovery consultor with no arm of its own while the + rewrite named it from the body's trailing expression — so `idg$Int` was + emitted at the call and never registered, and this check-green program + lost `main` exactly as the block-bodied arm did. No branches to exchange, + so the pairing convention does not apply here. + """ + assert _run(_GENERIC_HANDLE_ARG) == 42 + + @pytest.mark.parametrize( ("label", "source"), [ @@ -400,6 +530,18 @@ def test_no_codegen_skip_note_on_the_array_witness() -> None: _GENERIC_MATCH_TOTAL % _MATCH_TOTAL_DIVERGENT_FIRST, id="match_total", ), + pytest.param( + "match_block_arm", + _GENERIC_MATCH_BLOCK_ARM % f"{_THROW_ARM}, {_BLOCK_ARM}", + id="match_block_arm", + ), + pytest.param( + "if_nested_block", + _GENERIC_IF_NESTED_BLOCK % ( + _IF_DIVERGENT_FIRST, "throw(true)", _BLOCK_TAIL), + id="if_nested_block", + ), + pytest.param("handle_arg", _GENERIC_HANDLE_ARG, id="handle_arg"), ], ) def test_the_clone_is_named_for_the_real_instantiation( @@ -412,10 +554,23 @@ def test_the_clone_is_named_for_the_real_instantiation( the argument binds nothing — and it is an i32 clone reached with an i64 argument. Asserting its absence is what distinguishes "the type was inferred" from "the default happened to work". + + Membership is tested against `wat_fn_names` / `wat_calls` rather than + `"$idg$Int" in wat` (PR review): a substring test is a PREFIX test, so it + would also accept a longer mangled symbol — `$idg$IntAlias` satisfies a + check for `$idg$Int`, and a clone impersonating another clone is precisely + the failure this assertion exists to catch. Both helpers anchor on a + symbol boundary, and `wat_fn_names` prints what WAS emitted on failure. """ wat = _compile(source).wat - assert "$idg$Int" in wat, f"{label}: no Int clone in the module:\n{wat}" - assert "$idg$Bool" not in wat, ( + emitted = wat_fn_names(wat) + assert "idg$Int" in emitted, ( + f"{label}: no Int clone in the module; emitted: {emitted}" + ) + assert "idg$Bool" not in emitted, ( f"{label}: the phantom-var default was instantiated instead of the " - f"argument's real type:\n{wat}" + f"argument's real type; emitted: {emitted}" + ) + assert wat_calls(wat, "idg$Int"), ( + f"{label}: the Int clone is defined but not the call target:\n{wat}" ) diff --git a/vera/monomorphize.py b/vera/monomorphize.py index 8bcb6bb4..77818e50 100644 --- a/vera/monomorphize.py +++ b/vera/monomorphize.py @@ -2546,6 +2546,19 @@ def _infer_vera_type_name( return then_vt return self._infer_vera_type_name( expr.else_branch.expr, ctor_to_adt, generic_decls) + if isinstance(expr, ast.Block): + # #1286 (PR review): a `Block` names its TRAILING expression's + # type, exactly as the rewrite twin's `Block` arm does. This is + # not a defensive add — the transformer leaves a braced match arm + # body AS a `Block` (`Some(@Int) -> { let @Int = …; @Int.0 }`), + # and a braced `if` branch whose tail is itself braced likewise. + # Without the arm, discovery answered `None` for every + # block-bodied arm while the rewrite — which HAS the arm — named + # the concrete clone: `idg$Int` emitted at the call and never + # registered, so a check-green `main` was dropped (E602). Same + # divergence the `MatchExpr` arm below closes, one shape over. + return self._infer_vera_type_name( + expr.expr, ctor_to_adt, generic_decls) if isinstance(expr, ast.MatchExpr): # #1286: discovery had NO `MatchExpr` arm at all, so a `match` # argument answered `None` and the instantiation fell to the @@ -2559,6 +2572,15 @@ def _infer_vera_type_name( if arm_vt is not None: return arm_vt return None + if isinstance(expr, ast.HandleExpr): + # #1286 (PR review sweep): the third shape of the one gap — a + # `handle` in argument position is named from its body's trailing + # expression by the rewrite twin and by nothing here, so it + # dangled exactly like the block-bodied arm above. Measured, not + # inferred: `idg(handle[Exn] { … } in { 42 })` is + # check-green and drops `main` at E602 without this arm. + return self._infer_vera_type_name( + expr.body.expr, ctor_to_adt, generic_decls) if isinstance(expr, ast.StringLit): return "String" if isinstance(expr, ast.InterpolatedString): From 2082678ff49f752e4e2fb930b40844ac2e712520 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 19:27:39 +0100 Subject: [PATCH 52/69] Track the deferred IndexExpr shape as #1327 on every surface that mentions it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review sweep measured a fourth shape of the consultor-parallelism gap and this PR deliberately left it open: discovery has no `IndexExpr` arm, so `idg(@Array.0[1])` is check-green and then drops `main` with a dangling `idg$Int`, the rewrite consultor having an arm the monomorphizer does not. Closing it wants a shared element-type derivation rather than a partial copy — the rewrite's arm resolves chained indexing, aliases and `Future` payloads against codegen tables discovery does not carry, so half a mirror would replace a shape where both consultors answer "unknown" with one where they disagree. It is now filed, so the three surfaces that describe it carry the number: a KNOWN_ISSUES Bugs row (one-to-one with the tracker's open `bug` label), and the stamp in the test module's docstring, the TESTING.md row and the CHANGELOG bullet, each of which previously said only that the shape was left for its own change. The row is written from the measurement — the exact E602, the two consultors' disagreeing answers, and why the deferral is the sound call — and cross-links #1286 for the fix it was carved out of. This commit also carries the counts for the rebase onto #1323 and #1325: the conformance suite is 243 (upstream's 242 plus this PR's one run-level program), the corpus 292, and the pytest suite 11,670 across 174 files. Two of those are measurements rather than arithmetic. The suite breakdown is the run on this tree — 11,473 passed, 26 stress, 171 skipped — because the split has no honest source but the run. And the dual-target conformance differential was measured rather than assumed: `TestDualTargetConformance` reports 122 passed beside 52 loud skips on the union, so this PR's program joins the dual-tested side and the 45/6/1 exclusion breakdown is untouched. The corpus differential is re-measured against this base too: 0 movers over 291 pre-existing programs (commit 1's message cites 287, which was that figure at the previous base). Docs only; no source, test-code or behaviour change beyond the earlier commits. Co-Authored-By: Claude --- AGENTS.md | 8 +++--- CHANGELOG.md | 2 +- CLAUDE.md | 8 +++--- FAQ.md | 4 +-- KNOWN_ISSUES.md | 1 + README.md | 2 +- ROADMAP.md | 2 +- SKILL.md | 2 +- TESTING.md | 34 ++++++++++++------------- docs/SKILL.md | 2 +- docs/index.html | 2 +- docs/index.md | 2 +- docs/llms-full.txt | 14 +++++----- docs/llms.txt | 2 +- tests/test_infer_vera_type_join_1286.py | 15 ++++++----- vera/README.md | 2 +- 16 files changed, 52 insertions(+), 50 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 237b02c4..dc29f275 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Read `SKILL.md` for the full language reference. It covers syntax, slot referenc ### Conformance programs as reference -The conformance suite in `tests/conformance/` contains 242 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-seven negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) instead must *fail* with the E-code in their `expected_error` field, at the stage their `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. +The conformance suite in `tests/conformance/` contains 243 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-seven negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) instead must *fail* with the E-code in their `expected_error` field, at the stage their `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. ### Workflow @@ -187,9 +187,9 @@ Each stage is a module with a single public API function (`parse_file`, `transfo pytest tests/ -v # Run all tests (see TESTING.md) pytest tests/test_conformance.py -v # Conformance suite only mypy vera/ # Type-check the compiler -python scripts/check_conformance.py # All 242 conformance programs hold (positives pass; negatives fail with their E-code) +python scripts/check_conformance.py # All 243 conformance programs hold (positives pass; negatives fail with their E-code) python scripts/check_examples.py # All 42 examples must pass -python scripts/check_corpus_canonical.py # All 291 corpus programs in canonical form +python scripts/check_corpus_canonical.py # All 292 corpus programs in canonical form ``` Test helpers follow a pattern: `_check_ok(source)` / `_check_err(source, match)` / `_verify_ok(source)` / `_verify_err(source, match)`. See existing tests for examples. @@ -198,7 +198,7 @@ When implementing a new language feature, write the conformance program *first* ### Invariants -- All 242 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first +- All 243 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first - All 42 examples in `examples/` must pass `vera check` and `vera verify` - `mypy vera/` must be clean - `pytest tests/ -v` must pass diff --git a/CHANGELOG.md b/CHANGELOG.md index 51d8d201..9a3e83ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **The `ch09_json` conformance entry cites the section that exists.** Its manifest `spec_ref` read `Section 9.4.4`, a section Chapter 9 does not have — the chapter's `9.4` runs to `9.4.3 Map` and `Json` is `9.7.1`. -- **The Vera-level type namers join over a conditional instead of reading one branch** ([#1286](https://github.com/aallan/vera/issues/1286)). [#1276](https://github.com/aallan/vera/issues/1276) fixed the WAT result-type deciders to take the first branch that yields a type; their Vera-level siblings kept the one-branch read — `InferenceMixin._infer_vera_type` (the WASM call-rewrite consultor) read `then_branch` only and `arms[0]` only, and `Monomorphizer._infer_vera_type_name` (the instantiation-discovery consultor) read `then_branch` only and had no `MatchExpr` arm at all. A branch whose every path `throw`s names no type, so reading only that branch answered "unknown" for the whole expression, and the issue's latency estimate was wrong in the program's favour: the shape is constructible, and it is loud in two different ways from check-green source. As an **array-literal element** (`[if false then { throw(true) } else { 42 }, 7]`, and the `match` and `String` spellings of the same position) the unknown element type raised `CodegenSkip`, so a declared `public fn main` — `vera check`-green and `vera verify`-green at 2 Tier 1 — was absent from the compiled exports behind an `[E602]` note. As a **generic argument** (`idg(if false then { throw(true) } else { 42 })`, verify-green at 4 Tier 1) the type variable bound nothing and the clone fell to the phantom-var default: the module carried `idg$Bool`, an i32 clone, reached with an i64 argument, and failed to load with `Invalid input WebAssembly code at offset 73: type mismatch: expected i32, found i64`. The same reading through a constructor **field** mis-instantiated the unboxing clone the other way round (`expected i64, found i32`). The repair lands on both consultors together because the `match` case was broken in both directions: with every arm completing and nothing diverging, the rewrite named `idg$Int` from arm 0 while discovery, having no arm for `MatchExpr`, named the phantom default, and the caller was dropped on a dangling target — the clone-name agreement contract ([#772](https://github.com/aallan/vera/issues/772)) makes the pair, not either function, the unit of repair. All 287 pre-existing corpus programs (`examples/` plus `tests/conformance/`, recursive) emit byte-identical WAT, since the join only changes an answer that was previously unknown; `ch02_generic_arg_branch_join` promotes the witness into the conformance suite at level `run`. The review round closed the same divergence in two further shapes, both of them the one gap — the discovery consultor must stay structurally parallel to the rewrite one, arm for arm. It had no `Block` arm, and the transformer leaves a braced match-arm body AS a `Block`, so `idg(match … { Some(@Int) -> { let @Int = @Int.0 + 1; @Int.0 }, None -> throw(true) })` named nothing on the discovery side and `idg$Int` on the rewrite side: a dangling target that dropped `main` from check-green source. A braced `if` branch whose tail is itself braced does the same, and so does a `handle` in argument position, which likewise had no arm. An `IndexExpr` argument is measured to dangle identically and is deliberately left for its own change: the rewrite's arm resolves chained indexing, aliases and `Future` payloads against codegen tables the monomorphizer does not have, so a partial mirror would replace a shape where both consultors answer "unknown" with one where they disagree. +- **The Vera-level type namers join over a conditional instead of reading one branch** ([#1286](https://github.com/aallan/vera/issues/1286)). [#1276](https://github.com/aallan/vera/issues/1276) fixed the WAT result-type deciders to take the first branch that yields a type; their Vera-level siblings kept the one-branch read — `InferenceMixin._infer_vera_type` (the WASM call-rewrite consultor) read `then_branch` only and `arms[0]` only, and `Monomorphizer._infer_vera_type_name` (the instantiation-discovery consultor) read `then_branch` only and had no `MatchExpr` arm at all. A branch whose every path `throw`s names no type, so reading only that branch answered "unknown" for the whole expression, and the issue's latency estimate was wrong in the program's favour: the shape is constructible, and it is loud in two different ways from check-green source. As an **array-literal element** (`[if false then { throw(true) } else { 42 }, 7]`, and the `match` and `String` spellings of the same position) the unknown element type raised `CodegenSkip`, so a declared `public fn main` — `vera check`-green and `vera verify`-green at 2 Tier 1 — was absent from the compiled exports behind an `[E602]` note. As a **generic argument** (`idg(if false then { throw(true) } else { 42 })`, verify-green at 4 Tier 1) the type variable bound nothing and the clone fell to the phantom-var default: the module carried `idg$Bool`, an i32 clone, reached with an i64 argument, and failed to load with `Invalid input WebAssembly code at offset 73: type mismatch: expected i32, found i64`. The same reading through a constructor **field** mis-instantiated the unboxing clone the other way round (`expected i64, found i32`). The repair lands on both consultors together because the `match` case was broken in both directions: with every arm completing and nothing diverging, the rewrite named `idg$Int` from arm 0 while discovery, having no arm for `MatchExpr`, named the phantom default, and the caller was dropped on a dangling target — the clone-name agreement contract ([#772](https://github.com/aallan/vera/issues/772)) makes the pair, not either function, the unit of repair. All 291 pre-existing corpus programs (`examples/` plus `tests/conformance/`, recursive) emit byte-identical WAT, since the join only changes an answer that was previously unknown; `ch02_generic_arg_branch_join` promotes the witness into the conformance suite at level `run`. The review round closed the same divergence in two further shapes, both of them the one gap — the discovery consultor must stay structurally parallel to the rewrite one, arm for arm. It had no `Block` arm, and the transformer leaves a braced match-arm body AS a `Block`, so `idg(match … { Some(@Int) -> { let @Int = @Int.0 + 1; @Int.0 }, None -> throw(true) })` named nothing on the discovery side and `idg$Int` on the rewrite side: a dangling target that dropped `main` from check-green source. A braced `if` branch whose tail is itself braced does the same, and so does a `handle` in argument position, which likewise had no arm. An `IndexExpr` argument is measured to dangle identically and is deliberately left for its own change ([#1327](https://github.com/aallan/vera/issues/1327)): the rewrite's arm resolves chained indexing, aliases and `Future` payloads against codegen tables the monomorphizer does not have, so a partial mirror would replace a shape where both consultors answer "unknown" with one where they disagree. - **A user-defined `fn get` / `fn put` is no longer hijacked by an enclosing handler** ([#1284](https://github.com/aallan/vera/issues/1284)). Three sites answered "does this `get` mean the user's declaration or the effect operation?" independently. The checker answers user-fn-first — `_check_call_with_args` looks a bare name up as a function before it looks it up as an operation, so a declaration named `get` owns every bare `get(...)` in its scope, which an arity or argument-type error at such a call site proves by reporting the *user's* signature (E201/E202). Codegen answered twice more: the declared-effect row in `vera/codegen/functions.py` withheld the intrinsic when `_fn_sigs` already owned the name, and the handler expression in `vera/wasm/calls_handlers.py` installed `get`/`put` unconditionally. From `vera check`-green source that produced, depending on the nesting shape, a **silently wrong value** (`nat_to_int(get(3))` under `handle[State](@Int = 5)` returned the cell's 5 for the function's 4, and the argument was not even emitted), a **module WASM validation rejects** (a `@Bool`-returning user `get` took `state_get_Int`'s i64 into an `i32` position; different-family nesting took the *enclosing* cell's getter at the wrong width), or a **spurious `[E602]`** in which the #1233 unaddressable-cell gate refused `main` outright, naming "a bare or qualified State operation `get`" the program never contained. The repair is one predicate, `vera.slots.bare_call_denotes_user_fn`, stating the checker's rule once and consumed by the bare-call dispatch in `vera/wasm/calls.py` (which now gates the clause-inline registry, the host-cell intrinsics and the addressability gate together), by the three bare-`FnCall` result-type inference sites, and by the monomorphizer's discovery walk — each passing its own name table, so the sites cannot answer differently about the table they share. Gating the *dispatch* rather than the *registries* is what makes it correct rather than merely consistent: the registries record which cell an op name reaches, which is true whatever the program's declarations are called, and withholding an entry answered both questions with one table. That is why the gate-only fix measured during PR #1283's review turned the loud skip into a differently-broken module, and why it also cost the qualified spelling its cell — `State.put(5)` in a function that also declares `fn put` compiled to `call $vera.put` and failed to link, which now lowers to the intrinsic the checker always meant. Discovery's `MonoContext.fn_names` moves to the same lookup-time question, so a `get(())` fixing a generic's type variable under a handler names the clone the rewrite emits. All 256 conformance and example programs emit byte-identical WAT. **The `W002` async-commutativity warning was the same defect in the checker's own file** and is corrected with them: `_collect_expr_effects` asked `lookup_effect_op` before the scoped function lookup — the last op-first consumer — so a user function named after an operation contributed the *operation's* parent effect to the commutativity analysis instead of its own declared row, wrong in both directions. A **pure** `fn get`, in a program containing no `State` at all, drew `async argument performs State effects`; a `fn get` that performs `IO`, under a row naming `Http` first, drew **no warning**, because the walk bound the name to `Http.get`, which is inside the commutative whitelist, and silently withheld the eager-evaluation warning the program is owed. Both are pinned with rename controls — the byte-identical program with the helper called `gett` / `fetch` was correct throughout — beside a control that an unshadowed bare `get(())` under a `State` row still warns, so the fix cannot degenerate into never reporting `State`. The walk's comment claiming it resolves "like the call checker above" is now true. One caveat the predicate did not close on its own: codegen's name table was not scope-accurate, so a name the call site cannot see still answered "user-owned" there — a property of the table rather than of the rule, closed by [#1299](https://github.com/aallan/vera/issues/1299) below. diff --git a/CLAUDE.md b/CLAUDE.md index dde844be..9deeda26 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,10 +59,10 @@ VERA_EAGER_GC=1 vera run file.vera # Force GC on every alloc (see ENVIRONMENT.m VERA_DEBUG_HOST_ERRORS=1 vera run file.vera # Re-raise a host callback's own exception (see ENVIRONMENT.md, debug knob for host-binding bugs) mypy vera/ # Type-check the compiler itself -python scripts/check_conformance.py # Verify all 242 conformance programs (positives pass their level; negatives fail with their expected_error E-code) +python scripts/check_conformance.py # Verify all 243 conformance programs (positives pass their level; negatives fail with their expected_error E-code) python scripts/check_examples.py # Verify all 42 examples parse + check + verify python scripts/check_examples_run.py # Run every runnable example trap-free under the native runtime; the rest carry a documented skip property, and an example that is neither is an error -python scripts/check_corpus_canonical.py # Verify all 291 corpus programs are in canonical form (vera fmt) +python scripts/check_corpus_canonical.py # Verify all 292 corpus programs are in canonical form (vera fmt) python scripts/check_examples_readme.py # Verify vera run commands in examples/README.md python scripts/check_spec_examples.py # Verify spec code blocks parse python scripts/check_readme_examples.py # Verify README code blocks parse @@ -94,7 +94,7 @@ See [`TOOLCHAIN.md`](TOOLCHAIN.md) for the CLI cookbook — driving the toolchai - `vera/` — Reference compiler: grammar, parser, AST, transformer, type checker, verifier, codegen, CLI - `examples/` — 42 example Vera programs (all must pass `vera check` and `vera verify`) - `tests/` — Test suite (unit tests + conformance suite) -- `tests/conformance/` — 242 conformance programs validating every language feature against the spec +- `tests/conformance/` — 243 conformance programs validating every language feature against the spec - `scripts/` — CI and validation scripts ## Writing Vera code @@ -131,7 +131,7 @@ Before changing code — **adding or removing** — write the test that proves y ## What not to break - Pre-commit hooks run mypy + pytest + conformance suite + example validation on every commit -- All 242 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first +- All 243 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first - All 42 examples in `examples/` must pass `vera check` and `vera verify` - Version must stay in sync across `pyproject.toml`, `vera/__init__.py`, `docs/index.html`, `README.md`, and `uv.lock` (gated by `scripts/check_version_sync.py`); CHANGELOG.md must also carry a matching `## [X.Y.Z]` section - All tests must pass: `pytest tests/ -v` diff --git a/FAQ.md b/FAQ.md index 3e990049..4a7e13a0 100644 --- a/FAQ.md +++ b/FAQ.md @@ -236,7 +236,7 @@ None of this is Vera-specific, but it validates the design choices. The thesis i This is a real concern. LLMs are trained on trillions of tokens of Python, TypeScript, and JavaScript. A MojoBench study (NAACL 2025) found that even fine-tuned models achieved only 30–35% improvement over base models on Mojo code generation, illustrating the cold-start problem for new languages. -Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (242 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification. +Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (243 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification. ## How does Vera compare to Dafny / Lean / Koka / F*? @@ -279,7 +279,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 11,633 tests, including a 242-program conformance suite +- 11,670 tests, including a 243-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 7702568a..8f956cd3 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -8,6 +8,7 @@ Defects in shipped compiler, runtime, or tooling behaviour — this table matche | Bug | Issue | |-----|-------| +| Monomorphization's Vera-level type namer has no `IndexExpr` arm, so a generic argument that is an array index drops its caller. `idg(@Array.0[1])` — a `forall` identity called on an element of a local `Array` — is `vera check`-green and then dies at `vera run` with `[E602] Function 'main' body contains unsupported FnCall: call target 'idg$Int' not registered in this module`, leaving `Available exports: (none)`: instantiation discovery (`Monomorphizer._infer_vera_type_name`) answers nothing for the index expression and falls to the phantom-var `Bool` default, while the WASM call-rewrite consultor (`InferenceMixin._infer_vera_type`), which DOES have an `IndexExpr` arm, names `idg$Int` at the call — the two consultors disagreeing, which is the clone-name agreement contract's (#772) failure mode. Loud, never a wrong answer. Measured during #1286's review sweep and unchanged by its fix, which closed the same consultor-parallelism gap for `Block`, `MatchExpr`, `IfExpr` and `HandleExpr` — each an exact one-line mirror of its twin — and deliberately left this one: the rewrite's arm delegates to `_infer_index_element_type`, which resolves chained indexing, type aliases and representation-transparent `Future` payloads against codegen tables the monomorphizer does not carry, so a partial mirror would answer differently from the rewrite for those cases, replacing a shape where both consultors say "unknown" with one where they disagree. Fix direction: lift the element-type derivation into one helper both consultors call, rather than a second partial copy that can drift from the first — the shape #1286 argues for throughout. | [#1327](https://github.com/aallan/vera/issues/1327) | | A match binding's GC shadow-stack push is never popped, so the shadow stack grows with recursion depth until it traps. Both `_translate_match`'s scrutinee rooting and `_setup_match_arm_env`'s binding rooting push onto `$gc_sp` and leave it advanced for the rest of the body; a function restores `$gc_sp` only on the way out, so a recursive function whose body matches pushes once per live frame and eventually crosses `$gc_stack_limit`, whose overflow check is a bare `unreachable`. Bisected on a `decreases`-guarded recursion carrying one match: an ADT (i32) scrutinee survives depth 2046 and traps at **2047**, **identically before and after [#1305](https://github.com/aallan/vera/issues/1305)**, which is what makes this pre-existing rather than new — the i32 rooting has behaved this way since #705/#707. What #1305 changed is reach: a `String` / `Array` scrutinee now takes this path too, and because the pair form roots twice per frame (scrutinee pointer and binder pointer) it survives only 1364 and traps at **1365** — two thirds of the ADT depth, which is what the slot arithmetic predicts: an ADT frame roots the parameter and the binder, a pair frame roots the parameter, the scrutinee pointer and the binder pointer, so three slots per frame against two and 2047 x 2/3 = 1364.7. Loud in every case — `unreachable`, never a wrong value — but the trap names no source construct and the depth at which it arrives is an artefact of how many pushes a body happens to make. Fix direction: pop what a match arm pushes at the arm's end (or scope the rooting to the arm body), so the depth a program can recurse to stops depending on how many matches its body contains. | [#1322](https://github.com/aallan/vera/issues/1322) | | Codegen's built-in-container branches are tested before `_adt_layouts`, so a user `data` declaration named after a container is classified by the container's representation instead of its own. `_type_expr_to_wasm_type` answers `Array` with `i32_pair` before it ever consults the registered ADT layouts, so `private data Array { MkArr(Int) }` — a perfectly ordinary declaration spec §8.4.1 permits — has its parameter classified as a two-word pair rather than the one-word heap pointer its constructor actually builds. `Map`, `Set`, `Decimal`, `Tuple` and `Future` are inert instances of the same ordering (their branch answers `i32`, which is what the ADT branch would have answered anyway) and compile and run correctly; `Array` is the one name whose two answers differ, and it fails on both sides of [#1309](https://github.com/aallan/vera/issues/1309) — as an unassemblable `i32_pair` local before it, and as an `[E602]` skip after. That E602 currently misattributes the cause, since the message it inherits from the [#1305](https://github.com/aallan/vera/issues/1305) pair guard describes the scrutinee's representation rather than the declaration that produced it; the wording no longer names `String` / `Array` as the source types, but a reader still has to know that a user `data Array` is why a pair representation was claimed at all. Sibling of #1309 (which reordered the alias branch ahead of these same container branches) and of [#1316](https://github.com/aallan/vera/issues/1316) (which is the environment those branches are consulted in) — one function, three orthogonal questions. Fix direction: consult `_adt_layouts` before the built-in container branches, matching `_resolve_named`, where a DECLARED ADT precedes the built-in absorption. | [#1321](https://github.com/aallan/vera/issues/1321) | | The checker admits literal patterns over scrutinees that can never match them, which is the general disease behind the container-ADT case in [#1315](https://github.com/aallan/vera/issues/1315). `match { true -> 100, _ -> 200 }` and its `1 ->` integer twin are **check-green** — no diagnostic of any kind — although a `String` can equal neither a boolean nor an integer. Nothing downstream is obliged to catch this: codegen refuses the pair-represented spellings since [#1305](https://github.com/aallan/vera/issues/1305) only because a pair has no comparable scalar word, which is an accident of representation rather than a rule about patterns, and the same nonsense over a scrutinee that IS one word has no such backstop. The two holes are one question asked twice — #1315 is "does this constructor belong to this scrutinee's type", this is "does this literal belong to it" — and both fall out of the checker validating a pattern against the arm's own shape rather than against the scrutinee's type. Fix direction: type each pattern against the scrutinee, once, for every pattern kind; the exhaustiveness pass is where the constructor half already half-lives, but the literal half has no home at all today. | [#1320](https://github.com/aallan/vera/issues/1320) | diff --git a/README.md b/README.md index fdb31928..0387a8e3 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ cp /path/to/vera/SKILL.md ~/.claude/skills/vera-language/SKILL.md ## Project status -Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 11,633 tests, 95% Python code coverage, 242 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. +Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 11,670 tests, 95% Python code coverage, 243 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. The reference compiler — parser, AST, type checker, contract verifier (Z3), WASM code generator, module system, browser runtime, and runtime contract insertion — is working. The language specification is in draft across [14 chapters](spec/). diff --git a/ROADMAP.md b/ROADMAP.md index b31aa397..990ed872 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,7 +12,7 @@ Ordering derives from the design principles ([DESIGN.md](DESIGN.md)): verificati ## Where we are -11,633 tests, 242 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. +11,670 tests, 243 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. ## Stage 19 — The verification completeness sprint diff --git a/SKILL.md b/SKILL.md index 850b216b..765fb998 100644 --- a/SKILL.md +++ b/SKILL.md @@ -2461,7 +2461,7 @@ public fn main(@Unit -> @Unit) ## Conformance Suite -The `tests/conformance/` directory contains 242 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. +The `tests/conformance/` directory contains 243 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. Each program is organized by spec chapter (`ch01_int_literals.vera`, `ch04_match_basic.vera`, `ch07_state_handler.vera`, etc.) and the `manifest.json` file maps features to programs. When you need to see how a specific construct works, check the conformance program before reading the spec. diff --git a/TESTING.md b/TESTING.md index 151011fb..159445de 100644 --- a/TESTING.md +++ b/TESTING.md @@ -6,9 +6,9 @@ This is the single source of truth for Vera's testing infrastructure, coverage d | Metric | Value | |--------|-------| -| **Tests** | 11,633 across 173 files (~155,000 lines of test code; 11,436 passed + 26 stress, 171 skipped) | +| **Tests** | 11,670 across 174 files (~155,000 lines of test code; 11,473 passed + 26 stress, 171 skipped) | | **Compiler code coverage** | 95% Python, 87% JavaScript (CI minimum: 80%) | -| **Conformance programs** | 242 programs across 9 spec chapters, validating every language feature | +| **Conformance programs** | 243 programs across 9 spec chapters, validating every language feature | | **Example programs** | 42, all validated through `vera check` + `vera verify` | | **Spec code blocks** | 189 parseable blocks from 14 spec chapters: 92 parse, 86 type-check, 85 verify (the rest carry inline `vera:skip` annotations, #538) | | **README code blocks** | 4 Vera blocks (4 validated, 0 annotated) | @@ -43,7 +43,7 @@ pytest tests/test_runtime_traps.py::TestHostErrorDebugKnob1302 -v mypy vera/ # strict mode # Validation scripts -python scripts/check_conformance.py # conformance suite (242 programs, see manifest.json) +python scripts/check_conformance.py # conformance suite (243 programs, see manifest.json) python scripts/check_examples.py # 42 example programs python scripts/check_spec_examples.py # spec code blocks python scripts/check_readme_examples.py # README code blocks @@ -93,7 +93,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_lexical_fn_scope_1299.py` | 56 | 1,554 | #1299 — codegen's bare-call ownership table must be the CALL SITE's lexical scope. The #1284 predicate is one rule over two tables, and codegen's was `set(_fn_sigs)`: every symbol the whole compilation absorbed, including names the compiling body cannot see, so a bare `get(())` the checker resolved to a `State` operation was lowered as a call to some other declaration. Four routes, all check-green — an imported module's **private** `get`, a **public** one a selective import excludes, a `where` helper of a **`forall` parent** (which keeps a bare key beside its clone-qualified one where a non-generic parent's does not), and the ability operation `show`, which E151 does not reserve and which reaches the same table through the INTRINSIC gate rather than the op one. Where the widths agreed the module loaded and answered the invisible declaration's value (7007 for the cell's 42007); where they differed it failed to load; the generic-`where` route is always loud (`unknown func $get`). Every expected value is the checker's, PROVEN by a type oracle rather than assumed — the invisible `get` returns `@Bool` while the caller returns `@Int` from it and checks green — and each route carries a rename control. The visibility matrix (public/private × in-filter/excluded/wildcard × shadowed/unshadowed × direct/transitive) asserts the verify verdict and the runtime value together per cell. The two directions are pinned at once: the sibling loses the name, the generic TEMPLATE keeps it (asserted on the emitted instruction stream, since monomorphization supersedes the template and a value assertion would be green either way), and a lifted closure — compiled through its own `WasmContext` — inherits its parent's scope. Four table invariants sit beside them: the scoped set is a subset of the registry, every `$`-bearing key stays in it, prelude names stay in it, and every emission door supplies a declaration its own helpers | | `test_phantom_generic_instances_1271.py` | 14 | 358 | #1271 — discovery inside a still-generic scope must not instantiate a callee at an ENCLOSING scope's type VARIABLE. `pick(@U.1, @U.0)` inside `forall fn helper` bound `pick`'s variable to the NAME `U`, so a `pick$U` clone was emitted whose parameter has no WASM type and which the compilability pass then skipped with a loud `[E604]` — the noise that kept #1223's shapes out of the conformance suite. Drives the four #1223 fixtures plus a mutual-recursion shape (two sibling generic helpers under a generic parent, whose phantoms include one arriving through a callee's declared RETURN type, `leaf$W`), asserting on ONE compile that no clone is keyed by a type variable, that no E602/E604/E605 skip is emitted, AND that the genuinely concrete clone is still there — the third assertion being what separates the filter from an over-filter that would take the real instantiation with it. Plus the **primitive-spelled binder** matrix (`forall`, ``, ``, ``), each row instantiating a sibling at exactly the type its binder is spelled like — a shared `idw(5)` would have let every row but `Int` pass for free — asserted on the clone set AND on the program still running; with the `Q`-binder control that keeps a genuine type variable filtered, so the fix cannot degenerate into "never filter". That control CREATES a live phantom candidate — a generic helper under a generic parent, handing its callee an argument typed by its own binder — because a control that merely fails to create one holds under any filter including none; mutation-checked by disabling the filter, which turns it red | | `test_handle_exn_divergent_result_1276.py` | 10 | 391 | #1276 — a `handle[Exn]` whose clause body AND handled body both diverge emitted a result-LESS `block` into a result-expecting context: check-green, verify-green, rejected at load with `type mismatch: expected i64 but nothing on stack`. Four divergent shapes (the issue's Int rethrow, the `Byte` payload spelling #1269 unmasked, a three-deep rethrow chain, and a clause diverging through both arms of an `if`), each asserted on valid WASM AND on the observable — the OUTER handler's clause value, 1000. Paired with the Unit TWIN, which infers `None` for the same reason but DOES complete: it must keep running and its WAT must contain no `unreachable` at all. The pairing is the point — `result_wt is None` means two things wanting opposite lowerings, and a fix that terminated both would trap a program that runs. The MIRROR family covers a clause that throws on one path and COMPLETES on the other (`if` and `match` spellings), where the inference read only the `then` branch / arm 0, answered `None`, and left the completing path's value stranded in a result-less block; the `if` case appears twice with different thrown values so both the throwing and the completing path are exercised from one inference | -| `test_infer_vera_type_join_1286.py` | 26 | 576 | #1286 — the VERA-level siblings of #1276's WAT join. `InferenceMixin._infer_vera_type` (the WASM call-rewrite consultor) read `then_branch` only and `arms[0]` only, and `Monomorphizer._infer_vera_type_name` (the instantiation-discovery consultor) read `then_branch` only and had no `MatchExpr` arm at all — so a branch that throws, naming no type, decided the answer for the whole expression. Two symptoms from check-green (and, with contracts, verify-green) source: as an array-literal ELEMENT the `None` raised `CodegenSkip` and the declared `main` simply left the exports with a loud [E602] note, and as a GENERIC ARGUMENT it left the type variable unbound, so `idg$Bool` — the phantom-var default, an i32 clone — was emitted for an i64 `Int` argument and the module failed to load. Seven witnesses (array literal in the `if`, `match` and pair-representation `String` spellings; generic argument in the `if` and `match` spellings; the constructor FIELD behind the same conditional), each asserted on the value, on `main` surviving into the exports, and on the absence of the skip note — the drop is quiet at the value level once the function is gone. The seventh witness is the consultor-AGREEMENT case, where every arm completes and nothing diverges: the rewrite named `idg$Int` from arm 0 while discovery named the phantom default, and the caller was dropped on a dangling target — which is why the repair lands on both consultors together, the clone-name agreement contract (#772) making the pair the unit. Each witness carries its ARM-SWAPPED twin and the pair must agree, so the join property under test is order-invariance rather than a remembered value; a WAT assertion pins WHICH clone the module carries, since a value can be right for the wrong reason. The PR review round found the same divergence one shape over and the sweep it prompted found a third, both closed here: discovery had no `Block` arm, and the transformer leaves a braced match-arm body AS a `Block`, so `Some(@Int) -> { let … }` named nothing there while the rewrite named the concrete clone — `idg$Int` emitted and never registered, `main` dropped from a check-green program. It only reaches a wrong answer when no later arm yields either, so the witness pairs the block-bodied arm with a throwing one; the braced-`if` variant needs the branch TAIL to be a block in its own right, a `let` inside the branch being a statement. The third is a `handle` in argument position, a presence cell since it has no branches to exchange. An `IndexExpr` argument dangles the same way and is deliberately NOT closed here — the rewrite's arm resolves chained indexing, aliases and `Future` payloads against codegen tables the monomorphizer lacks, so a partial mirror would trade "both say unknown" for "the two disagree". WAT membership is tested through `wat_fn_names` / `wat_calls`, not `in wat`: the substring form is a prefix test that a longer mangled symbol satisfies, which is exactly how one clone impersonates another. Mutation-checked one edit at a time: reverting the rewrite-side `if` fails 13, its `match` 8, the discovery-side `if` 8, `match` 9, `Block` 6 and `HandleExpr` 2, and all six at once fails all 26 | +| `test_infer_vera_type_join_1286.py` | 26 | 577 | #1286 — the VERA-level siblings of #1276's WAT join. `InferenceMixin._infer_vera_type` (the WASM call-rewrite consultor) read `then_branch` only and `arms[0]` only, and `Monomorphizer._infer_vera_type_name` (the instantiation-discovery consultor) read `then_branch` only and had no `MatchExpr` arm at all — so a branch that throws, naming no type, decided the answer for the whole expression. Two symptoms from check-green (and, with contracts, verify-green) source: as an array-literal ELEMENT the `None` raised `CodegenSkip` and the declared `main` simply left the exports with a loud [E602] note, and as a GENERIC ARGUMENT it left the type variable unbound, so `idg$Bool` — the phantom-var default, an i32 clone — was emitted for an i64 `Int` argument and the module failed to load. Seven witnesses (array literal in the `if`, `match` and pair-representation `String` spellings; generic argument in the `if` and `match` spellings; the constructor FIELD behind the same conditional), each asserted on the value, on `main` surviving into the exports, and on the absence of the skip note — the drop is quiet at the value level once the function is gone. The seventh witness is the consultor-AGREEMENT case, where every arm completes and nothing diverges: the rewrite named `idg$Int` from arm 0 while discovery named the phantom default, and the caller was dropped on a dangling target — which is why the repair lands on both consultors together, the clone-name agreement contract (#772) making the pair the unit. Each witness carries its ARM-SWAPPED twin and the pair must agree, so the join property under test is order-invariance rather than a remembered value; a WAT assertion pins WHICH clone the module carries, since a value can be right for the wrong reason. The PR review round found the same divergence one shape over and the sweep it prompted found a third, both closed here: discovery had no `Block` arm, and the transformer leaves a braced match-arm body AS a `Block`, so `Some(@Int) -> { let … }` named nothing there while the rewrite named the concrete clone — `idg$Int` emitted and never registered, `main` dropped from a check-green program. It only reaches a wrong answer when no later arm yields either, so the witness pairs the block-bodied arm with a throwing one; the braced-`if` variant needs the branch TAIL to be a block in its own right, a `let` inside the branch being a statement. The third is a `handle` in argument position, a presence cell since it has no branches to exchange. An `IndexExpr` argument dangles the same way and is deliberately NOT closed here, tracked as #1327 — the rewrite's arm resolves chained indexing, aliases and `Future` payloads against codegen tables the monomorphizer lacks, so a partial mirror would trade "both say unknown" for "the two disagree". WAT membership is tested through `wat_fn_names` / `wat_calls`, not `in wat`: the substring form is a prefix test that a longer mangled symbol satisfies, which is exactly how one clone impersonates another. Mutation-checked one edit at a time: reverting the rewrite-side `if` fails 13, its `match` 8, the discovery-side `if` 8, `match` 9, `Block` 6 and `HandleExpr` 2, and all six at once fails all 26 | | `test_generic_under_generic_callees_1223.py` | 8 | 298 | #1223 — a generic `where`-helper under a GENERIC parent instantiates its own generic callees. The helper is monomorphized only during clone hoisting, outside the worklist that rescans every clone it emits, so a top-level generic called from the helper body was discovered only in its still-generic spelling (`pick$U`, binding the enclosing type variable's NAME) while the rewrite called `pick$Bool` — E602 skip, E620 drop of the parent and of `main`, "No exported functions" from a check-clean, verify-clean program. Four shapes — a user generic, the prelude twin (`option_unwrap_or`), two levels of generic nesting where the INNER helper is the caller, and the non-generic-parent control that compiled before the fix and must keep compiling (it is what proves the trigger is the generic ancestor rather than the nested helper) — each asserted on no E602/E620, the checker's run value (the helper's argument order is non-commutative, so a miswiring gives 7 instead of 3), and a REGISTERED-vs-RESOLVED differential. That differential captures the emitted mono-decl names rather than `_emitted_instances` (whose generic-under-generic entries are keyed by the concrete-free lexical chain, not by the per-clone emission name the rewrite calls) and captures the rewrite side on `_resolve_generic_call` rather than from the WAT, because a desync skips the calling function and removes the dangling `call` along with it. The verifier's half of the pair is pinned in `test_monomorphize_differential.py`'s inline corpus, not here | | `test_mono_effect_op_naming_1207.py` | 9 | 339 | #1207 — monomorphization discovery and the WASM call-rewrite name ONE clone when an effect operation fixes a generic's type argument. A differential over the two consultors, not a unit test on either: the compiler's own E602 ("call target not registered in this module") IS the two sides disagreeing, so each case asserts no E602/E620, and additionally pins WHICH name they agreed on — an alignment on the wrong one still fails. Four instantiation-driving shapes (`get(())` as an array-literal element under a plain `State` cell, under a `type Count = Nat` alias cell whose clone must be `pick$Count`, in a function whose operation comes from the DECLARED effect row rather than an enclosing `handle`, and in direct argument position), plus the `array_append` builtin-argument control and a shadowed-name control — a user `get(@Unit -> @Bool)` is NOT an effect op in a declared row, so the clone must be `pick$Bool` and not the cell's; that case is green before the fix as well as after, which is what makes it a guard against the alignment over-reaching rather than a second copy of the repro | | `test_effect_op_determinism.py` | 9 | 504 | #1215 — bare effect-op resolution order: the built-in `State` and `Http` both declare `get`, so `effects(, Http>)` is a two-candidate row with no user `effect` declaration needed. The two candidate bindings are made to produce DIFFERENT observables (the source-order program runs to `70`; the reversed row is a loud `E217` naming `Http.get`), swept across six `PYTHONHASHSEED` values in child interpreters so a frozenset-order flip cannot pass — plus the innermost-handler-beats-declared-row precedence case, a signature-level assertion that the resolved `OpInfo` follows the recorded order both ways (including the deterministic name tiebreak for a row member no order tuple mentions), and the qualified-lookup control. The type-ARGUMENT sibling rides here too: `effects(, State>)` (two independent cells, spec §7.3.3) had the identical frozenset dependence in `_effect_type_mapping`, and codegen took the LAST instantiation in the row where the checker takes the first — both now source-order-first, swept the same way. A sixth sweep covers the public `ordered_effect_row()` fallback for a row member no order tuple mentions: its two members share the effect NAME and differ only in type ARGUMENT, so a name-only sort key ties them and hands back frozenset order — the order AND the `_effect_type_mapping` selection it drives are both asserted stable across the same seeds. Two further sweeps take that structural key down a level: a type argument may itself be a FUNCTION type, whose own effect row was rendered by `pretty_effect` — so two outer instances differing only INSIDE a nested row (by a refinement's predicate, or by a type variable's built-in marker) tied again, and both legs are asserted single-outcome across the same seeds, separately, so a regression names the elision that came back | @@ -105,7 +105,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_checker_errors.py` | 73 | 1,196 | Error codes, resolution-coverage diagnostics, contracts, error accumulation (#420 split); cyclic type aliases incl. #1059 self-reference through a type argument (`Future`, mutual `Future`/`Future`, `Array`) rejected E132 | | `test_checker_builtins_collections.py` | 97 | 848 | Map / Set / Decimal / Json / Html / Http / Inference built-in type-checking (#420 split) | | `test_checker_builtins_strings.py` | 122 | 945 | String / numeric / type-conversion / float-predicate / string-search / markdown / regex built-in type-checking, removed-legacy-name regression (#420 split) | -| `test_obligations.py` | 769 | 1,741 | Reified proof obligations + warm `VerificationSession` (#222 Phase A): full-corpus differential oracle (warm session == cold `verify()` on diagnostics, summary, and obligation stream, plus warm-twice determinism, across all 42 examples and every verify/run-level conformance program), summary↔obligation tier-bookkeeping consistency (including the #967 `total == tier1_verified + tier3_runtime` leg, plus a focused self-consistency pin on the three call-demotion examples), the #1242 stream partition — over a corpus widened to every conformance program that type-checks, at any level, `len(obligations) == total + violated + tier3_unguarded` and every status is one of the documented five, with the vocabulary read from the `ObligationStatus` Literal so a sixth member fails rather than vanishing from the counts — per-kind unit tests (requires / ensures / decreases / nat_sub / call_pre statuses, counterexamples, error codes), content-key stability + same-text-two-sites span disambiguation, session solver reuse, type-error short-circuit, ADT-registry resync between programs; plus the Phase B incremental suite — identical-source full replay, callee-body-edit replays callers while callee-contract-edit invalidates them, span-shift and ADT-edit conservative invalidation, cross-program isolation, timeout-status never cached (monkeypatched solver), FIFO eviction bound; plus the #727 dedup pin — a violating call in a let RHS records exactly one E501 diagnostic and one call_pre obligation; plus the #1208 call-site rendering pin — a PARAMETERISED callee slot substitutes into the E501 message and its fix instead of falling back to the generic wording | +| `test_obligations.py` | 772 | 1,741 | Reified proof obligations + warm `VerificationSession` (#222 Phase A): full-corpus differential oracle (warm session == cold `verify()` on diagnostics, summary, and obligation stream, plus warm-twice determinism, across all 42 examples and every verify/run-level conformance program), summary↔obligation tier-bookkeeping consistency (including the #967 `total == tier1_verified + tier3_runtime` leg, plus a focused self-consistency pin on the three call-demotion examples), the #1242 stream partition — over a corpus widened to every conformance program that type-checks, at any level, `len(obligations) == total + violated + tier3_unguarded` and every status is one of the documented five, with the vocabulary read from the `ObligationStatus` Literal so a sixth member fails rather than vanishing from the counts — per-kind unit tests (requires / ensures / decreases / nat_sub / call_pre statuses, counterexamples, error codes), content-key stability + same-text-two-sites span disambiguation, session solver reuse, type-error short-circuit, ADT-registry resync between programs; plus the Phase B incremental suite — identical-source full replay, callee-body-edit replays callers while callee-contract-edit invalidates them, span-shift and ADT-edit conservative invalidation, cross-program isolation, timeout-status never cached (monkeypatched solver), FIFO eviction bound; plus the #727 dedup pin — a violating call in a let RHS records exactly one E501 diagnostic and one call_pre obligation; plus the #1208 call-site rendering pin — a PARAMETERISED callee slot substitutes into the E501 message and its fix instead of falling back to the generic wording | | `test_verifier_contracts.py` | 95 | 898 | Z3 verification over the example corpus, trivial/ensures/if-else/let/multi-clause contracts, counterexamples, tier classification, arithmetic, verification summaries, Diverge effect, edge cases, string-length + string-predicate verification (#839 split) | | `test_verifier_nat_obligations.py` | 82 | 1,743 | **`@Nat` subtraction underflow obligation** (#520 — Path-A discharge via requires/path-conditions/path-aware Z3 refutation, pure-literal exclusion, Int-Int and Nat-Int exemptions) and **`@Nat` binding-site narrowing obligation** (#552/#747/#749 — Tier-1 `value >= 0` at let/call-arg/effect-op-arg/ctor-field/match-bind/destructure narrowing — a concrete site classifies `tier3_runtime` (codegen-guarded) while the effect-op argument and generic-instantiated constructor field classify `E504` (obligated but unguarded, #754/#757) whose rationale names its actual cause — an untranslatable value — rather than the untranslatable-or-timeout conflation #1251 removed, walker-recursion pins, `_narrows_into_nat` verifier/codegen soundness parity; PR #972 clone-instantiated side-table substitution — a `Some(@T)` bind in an `Option`-instantiated clone is no narrowing, genuine clone-path narrowings still obligated); #1201 — a builtin `Tuple` parameter's match-bound components carry their declared component facts (a valid ensures over one proves instead of falsely violating) and an `Int` component bound as `@Nat` fires one loud `E503` per component, both mutation-caught (#839 split) | | `test_verifier_primitive_ops.py` | 39 | 662 | **Primitive-operation safety obligations** (#680) — division/modulo by-zero `E526` and array-index-bounds `E527`, the in-bounds/out-of-bounds two-check with float-exemption, honest Tier-3 for opaque lengths, off-by-one and lower-bound pins, De Bruijn-correct fix hints (#839 split) | @@ -123,7 +123,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_soundness_392.py` | 36 | 584 | #392 audit batches 1–2 — verifier soundness/completeness fixes: signed div/mod truncate toward zero (#799), body `assert(P)` carries a Tier-1 obligation (#800), divisions in contract predicates carry a `div_zero` obligation (#801), and the #804 assume-half of #800's `assert` rule — a prior `assert`/`assume` discharges later obligations (including a later call's precondition) + the postcondition at Tier 1, removing false E501/E503/E500/E505 | | `test_int_overflow.py` | 6 | 143 | #798 — `@Int`/`@Nat` arithmetic-overflow obligations (part of the #392 `smt.py` soundness audit): `+`/`-`/`*` on `@Int`/`@Nat` now emit an `int_overflow` obligation (the analog of `nat_sub`/`div_zero`) rather than modelling the operands as Z3's unbounded integers, so a `ensures(@Int.result > @Int.0)` over `@Int.0 + 1` no longer proves a contract the i64/u64 runtime violates under two's-complement wraparound. Unbounded operands leave the obligation undischarged (Tier-3, runtime-guarded); operand bounds that prove the result stays in range discharge it at Tier 1 | | `test_int_overflow_codegen.py` | 62 | 718 | #798 Stage 3 — runtime overflow-trap codegen: the codegen emits a guard at *exactly* the `@Int`/`@Nat` `+`/`-`/`*` sites the verifier obligates, so `vera run`/`vera compile` programs trap on overflow instead of silently wrapping at the i64/u64 boundary. #808 wired the guard to the `vera.overflow_trap` host import, so the trap now classifies `kind="overflow"` (carrying the overflow Fix paragraph) rather than the generic `unreachable`; `TestOverflowTrapKind808` pins that, with controls proving the #520 `nat_sub` underflow and #813 `@Nat`→`@Int` widen guards still classify `unreachable` | -| `test_int_overflow_differential.py` | 257 | 398 | #798 Stage 3 verifier↔codegen classification differential (cross-component soundness rule): the codegen overflow guard must fire at exactly the sites the verifier obligates *and* classify each site's operand type (`@Int` i64 vs `@Nat` u64) identically — else a Tier-1-clean program traps spuriously or a wrapping op slips through unguarded. Over a corpus exercising all five operand combos plus the literal-left ambiguity (a naive codegen mis-classifies it as `@Nat`), asserts the verifier's per-site gated classification equals the codegen's site for site, both sides driven by the same `ast.span_key` | +| `test_int_overflow_differential.py` | 258 | 398 | #798 Stage 3 verifier↔codegen classification differential (cross-component soundness rule): the codegen overflow guard must fire at exactly the sites the verifier obligates *and* classify each site's operand type (`@Int` i64 vs `@Nat` u64) identically — else a Tier-1-clean program traps spuriously or a wrapping op slips through unguarded. Over a corpus exercising all five operand combos plus the literal-left ambiguity (a naive codegen mis-classifies it as `@Nat`), asserts the verifier's per-site gated classification equals the codegen's site for site, both sides driven by the same `ast.span_key` | | `test_nat_int_widening.py` | 36 | 602 | #813 — `@Nat -> @Int` widening coercion obligation (dual of #552 `nat_bind`, part of the #392 soundness audit): a `@Nat` in (i64.MAX, u64.MAX] reinterprets when widened (u64.MAX → -1), so a `nat_to_int_coerce` obligation that the value is `<= i64.MAX` now fires at the return position — provably-in-range → Tier-1, provably-out-of-range (`@Nat.0 >= 2**63`) → loud E530, unbounded → honest Tier-3 (runtime-guarded), with an `@Int -> @Int` control that must not fire; the unguarded generic-`@Int`-field case also has its `E531` rationale read for WHAT IT SAYS — a value bounded on neither side, not the untranslatable-or-timeout conflation #1251 removed. The #813 follow-up adds the explicit `nat_to_int` built-in and heterogeneous `if`/`match` arms with a non-negative-literal alternative; #820 adds the heterogeneous-`@Int`-slot arm, closure argument, and closure return/capture obligations (each per-arm / per-site, with `@Int`-arm and `@Nat`-formal controls that must not fire) | | `test_int_widening_codegen.py` | 52 | 535 | #813 Stage 3 — runtime `@Nat -> @Int` widening-trap codegen: the codegen emits a guard at *exactly* the `@Nat -> @Int` coercion sites the verifier obligates (return, `let`, call argument, and — since #820 — array element, tuple construction/destructure, heterogeneous `if`/`match` arm, closure argument/return), so `vera run`/`vera compile` programs trap when a `@Nat` above i64.MAX would reinterpret to a negative `@Int` instead of silently returning the wrong value. The trap is a bare `unreachable` (shares `_emit_negative_i64_guard` with the #552 nat-bind guard), classified `kind="unreachable"` today (a dedicated widening trap kind is a follow-up) | | `test_int_widening_differential.py` | 26 | 320 | #813 verifier↔codegen behavioural differential (cross-component soundness rule): at every `@Nat -> @Int` coercion site the verifier's `nat_to_int_coerce` classification must AGREE with the runtime — a `tier3` (codegen-guarded) site MUST trap on a `@Nat` above i64.MAX (return / `let` / call-arg / constructor field / ADT sub-pattern / match-bind, and the #820 array-element / tuple-component / heterogeneous-arm / closure argument-return sites), while a `tier3_unguarded` (E531) site must NOT trap (the generic-instantiated `@Int`-field coercion codegen cannot guard). Runs BOTH sides on one corpus so the "runtime-guarded" claim is checked against the actual trap — catching a verifier deferral codegen never guards (unsound silent -1) or a spurious trap | @@ -192,7 +192,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_string_length_soundness.py` | 15 | 278 | #802 — string_length code-point vs UTF-8 byte soundness: a non-literal `string_length` defers to Tier 3 (the issue's `"é"` probe no longer proves `== 1` at Tier 1), a string-literal length is modeled at its exact UTF-8 byte count (`== 2` for `"é"`), and the boolean predicates `string_contains` / `string_starts_with` / `string_ends_with` stay Tier 1 (sound under UTF-8 self-synchronization), while a predicate over an astral (> U+2FFFF) or lone-surrogate literal defers to Tier 3 (z3.StringVal cannot model those code points) | | `test_errors.py` | 62 | 657 | Error code registry, diagnostic formatting, serialisation, SourceLocation, and error display sync — the canonical `E001` diagnostic must match each of its mirrors: `README.md`, `docs/index.html`, `spec/00-introduction.md`, `AGENTS.md`'s example `--json` block, and the hardcoded example in `scripts/build_site.py` that generates `docs/index.md` (#829; `AGENTS.md`'s ellipsis-truncated description/rationale are prefix-compared, its `error_code`/`spec_ref`/`fix` exactly) | | `test_eq_contract_874.py` | 13 | 430 | `eq`/`compare` ability ops in contract position: codegen canonicalization + verifier Tier-1 discharge/counterexample, where-fn contracts, compare Ordering-sort materialization, shadowing guard (#874) | -| `test_formatter.py` | 537 | 3,638 | Comment extraction, interior comment positioning, expression/declaration formatting, match arm block bodies, §1.8 rule 2 in value position (a `let`-bound `match`/`if` expands exactly as one in statement position, and a comment above an arm inside a statement's value stays on that arm), blank-line preservation (§1.8 rule 13 — gaps between statements, before a block result and around a comment, collapsed to one and never invented), idempotency, parenthesization, spec rules, ability declarations | +| `test_formatter.py` | 538 | 3,638 | Comment extraction, interior comment positioning, expression/declaration formatting, match arm block bodies, §1.8 rule 2 in value position (a `let`-bound `match`/`if` expands exactly as one in statement position, and a comment above an arm inside a statement's value stays on that arm), blank-line preservation (§1.8 rule 13 — gaps between statements, before a block result and around a comment, collapsed to one and never invented), idempotency, parenthesization, spec rules, ability declarations | | `test_cli.py` | 273 | 4,612 | CLI commands (check, verify, compile, run, serve, test, fmt, version, quiet), subprocess integration, JSON error paths (including the `verify --json` `obligations` array and its summary-reproducibility pin, #967, and the #1242 partition pin — the array is emitted unfiltered, a refuted obligation is counted by no summary field, and it still joins its E500 on the location key), runtime traps, arg validation, multi-file resolution, IO exit codes, --explain-slots (including the #1208 naming pins — an alias in type-argument position is tabled resolved, and a `forall` variable shadowing a module alias keeps the two parameter stacks apart — and the #1217 `where`-helper tables: the helper prints indented under its parent, appears in the JSON qualified as `parent.helper`, and inherits the enclosing `forall` variables so the shadowing holds inside it too), `builtins`/`effects`/`errors` introspection dispatch, and a USAGE-completeness guard (every dispatched `cmd_` handler has a help row) | | `test_introspect.py` | 39 | 221 | `vera builtins/effects/errors --json` registry introspection (#539): the `{schema, items}` envelope, count-equals-registry differential per registry, error-phase derivation, effect/ability `kind` tagging, the parameterised `Exn` effect, and best-effort `since` attribution with full-coverage guards | | `test_resolver.py` | 20 | 602 | Module resolution, path lookup, parse caching, circular import detection, the E011/E012/E013 diagnostic contract, internal-error isolation (a compiler bug is not masked as E013), and the transitive-closure return of `resolve_imports` (#890 — a diamond yields each reachable module once, direct imports tagged `direct`, the transitive one not) | @@ -210,7 +210,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_markdown.py` | 94 | 610 | Markdown parser: block/inline parsing, rendering, round-trips, edge cases | | `test_lsp.py` | 146 | 2470 | LSP transport + coordinate layer (#222 Phase C) and language features (#222 Phase D): parametrized code-point↔UTF-16 goldens incl. astral-plane fixtures and surrogate-pair snapping, Span (1-based, exclusive-end) and SourceLocation (0-based col) → LSP Range conversions, point→token-range widening, DocumentStore open/change/close + index invalidation, an in-process handler-drive test, and one stdio end-to-end round-trip against the real `vera lsp` subprocess (initialize → didOpen → shutdown → exit) pinning serverInfo + textDocumentSync capabilities; plus the Phase D feature suite — parse-error single-diagnostic path, type-error verification short-circuit, tier=3 in E520 diagnostic data, per-function tier Hint synthesis (and its suppression for functions with violated obligations), smallest-enclosing-span hover, De Bruijn slot goto (most-recent-parameter jump, out-of-range None, off-slot None, and the #1208 keying pins: a parameterised reference resolves, an alias-spelled parameter is reachable from a canonically-spelled reference, and a `forall` variable shadowing a module alias lands on the right parameter), and typed-hole completion (inside/after hole, away-from-hole None); plus the Phase E speculativeEdit suite — identical-text all-unchanged, breaking edit surfaces newly_undischarged (violated nat_sub) with canonical state untouched, strengthening edit surfaces newly_discharged, parse/type errors report ok:false, deleted functions report removed, proof_delta purity; plus the Phase F1 proposeEdit suite — the apply gate (clean and strengthening edits apply, breaking and non-compiling edits refuse), force overriding both gates with the delta still reported, wiring against a structural fake server (apply round-trip with exact full-document replacement range, refuse touches no canonical state, unopened-URI clamp sentinel), and full-document-range goldens (trailing-newline virtual line, UTF-16 end column); plus the Phase F2 strengthenContract suite — splice goldens (first-clause-only replacement with byte-identical remainder, ensures variant, unknown-fn None), the call-site audit pin (tightened precondition refused with newly_undischarged call_pre items, canonical state untouched), provable-ensures strengthening applies, and the three splice-target refusal paths (no analysis, unparseable document, unknown function); plus the Phase F3 addEffect suite — transitive-caller closure goldens (diamond in declaration order, leaf, unknown-fn None, recursion appears once), handler bounding (#725: a caller discharging the effect around its only call site drops out of the closure and is not rewritten, while a second unhandled path, a call in a handler clause, a handler for another effect, a handler naming a different instance of the same effect (`handle[State]` against a `State` propagation, which the checker does not discharge — end-to-end that caller must still be rewritten and the candidate must still apply, with the matching `State` propagation against the same fixture as the positive control that this handler key does prune something), and a bare `where`-helper call all keep it in, as does a refinement type argument at either depth (`Exn<{ @Int \| p }>` and `Exn>` render as their bare base type but discharge nothing of `Exn` — also pinned end-to-end), while an unparameterised `handle[IO]` does bound an `IO` propagation, a whitespace-spelled `State< Int >` request still bounds, and nesting bounds in either order (a matching handler inside a foreign one, a foreign one inside a matching one) — plus a key-level pin that `handle[Mod.IO]` keeps its module, and the effect-less query pinned handler-unaware; plus the two boundary pins the review added — a call in the handler's STATE INITIALISER keeps its edge, since the initialiser is evaluated in the enclosing scope before the handler is installed (pinned beside the E125 the checker raises there against a `pure` caller, with the identical call in the handler body clean as the contrast), and an alias-spelled handler does not bound a `State` propagation though the checker discharges it (`handle[State]` with `type MyAlias = Int` — the spelling comparison's under-prune, #1292, with the alias-spelled request as the control that does prune)), effect-row rewrite goldens (pure to singleton set, source-preserving append, already-present None, base-name identity blocking State next to State), diamond propagation applying one multi-site candidate with the bystander untouched, mixed append/replace rows with already-satisfied callers skipped, the fully-satisfied no-op shape, and the two refusal paths; plus the #728 instruction-contract suite — the LSP message carries description, rationale, and the Fix: paragraph (also pinning single E501 emission at the LSP surface), and a bare diagnostic maps to the description alone | | `test_browser.py` | 413 | 5,094 | Browser parity: Python/wasmtime vs Node.js/JS-runtime output equivalence across IO, State, contracts, Markdown, Regex, and the examples the browser target can execute (two explicit lists in the file, not the whole `examples/` directory — interactive stdin, file IO, `DB` and the non-standalone `modules` example are excluded with their reasons recorded); plus the #349 `runtime.mjs` coverage battery — per-value-type and per-key-type Map variants, per-element-type Set variants, cold `Decimal` branches (exact-zero sign, negative-shift division, `decRoundPlaces` special cases, non-finite storage), `readJson`/`json_stringify` across every Json ADT tag, the Regex/Json `Result.Err` arms, and nested-Markdown walks. Two operations carry a canonical form the specification states rather than merely agreeing across the hosts, so their batteries assert more than equality: `json_stringify` (spec §9.7.1) pins the expected string on every Json ADT tag and on the number-rendering boundaries, checks three-pass idempotence, checks that a non-finite `JNumber` fails on both hosts *and* prints nothing, pins the object key orders an ordinary JS object cannot carry (array-index keys, which ES enumeration hoists to the front in ascending numeric order, and a `__proto__` key, whose assignment writes a prototype instead of a field) on both a parsed and a program-built object, and checks the reference host's own number rendering differentially against a real `JSON.stringify` over doubles drawn from raw bit patterns; `md_render` (§9.7.3) pins the expected render, re-renders it to prove the fixed point, runs the round-trip property over a corpus carrying the container and multi-line shapes a flat corpus misses, and renders `MdBlock` values the test builds directly, since several renderer rules — a container's child separator, an empty container, a code span wider than one backtick — are unreachable through `md_parse`. plus the #1306/#1308 accept-domain battery (`TestBrowserJsonAcceptDomainParity1306_1308`), which runs one `.wasm` under both runtimes and compares the WHOLE stdout — arm taken and `Err` message together — over the JavaScript constants, over numbers that overflow to an infinity in both their exponent and integer spellings, and over lone surrogates at every position a string can occupy, with the expected sentences imported from `vera/wasm/json_serde.py` so `runtime.mjs`'s hand-copied duplicates are held against the originals, beside acceptance controls (matched pairs, finite boundary values, underflow to `0`, `"NaN"` as a string value), a ten-case host-native-message battery pinning that neither the substitute-and-re-parse probe nor the value-start constraint hijacks an unrelated syntax error (`-NaN` is the case needing both rules — the substitution alone turns it into `-0` and manufactures a refusal the reference host never makes), a precedence case fixing that a non-finite constant outranks a lone surrogate on both hosts though each reaches that answer by a different route, and a document-order case fixing that overflow and lone surrogate are resolved by one walk rather than by two per-host precedence rules that would agree on every single-violation document; `md_parse` itself is not yet at parity and its remaining divergence classes are a tracked bug ([#1301](https://github.com/aallan/vera/issues/1301)); the suite pins the shapes the two implementations agree on | -| `test_conformance.py` | 1210 | 144 | Parametrized conformance suite: parse, check, verify, run, format idempotency across 242 programs; a negative entry fails at the stage `expected_error_stage` names (`check`, the default, or `compile` — which also asserts the program type-checks cleanly first) | +| `test_conformance.py` | 1215 | 144 | Parametrized conformance suite: parse, check, verify, run, format idempotency across 243 programs; a negative entry fails at the stage `expected_error_stage` names (`check`, the default, or `compile` — which also asserts the program type-checks cleanly first) | | `test_prelude.py` | 29 | 585 | Prelude injection: Option/Result/array operation detection, combinator shadowing, type aliases, the reserved namespace every injected alias declaration lives in — checked against the checker's own E154 regex rather than a second spelling of the rule, since an alias the prelude injects outside it is one codegen resolves and the checker leaves opaque (#1184/#1221) — end-to-end compilation | | `test_checker_apply_fn.py` | 18 | 455 | #854 — `apply_fn` as a checker special form: zero-warning pins (API + CLI `--json` + closures.vera), E201 arity / E202 type / non-function-first-arg errors, E122/E125 effect-row enforcement for applied fn values, E151 redefinition rejection, variadic two-param application, prelude combinator regression pins | | `test_prelude_diagnostics.py` | 8 | 271 | #851 — prelude combinator skip-warnings: unreferenced-prelude E602/E604 suppression (zero-warning minimal compile, API + CLI `--json`), `` origin attribution for referenced-but-skipped combinators (text + `to_dict`), transitive reference scan, and user-fn warning locations pinned unchanged | @@ -232,11 +232,11 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_doc_builtin_shadowing.py` | 8 | 107 | `check_doc_builtin_shadowing.py` gate ([#819](https://github.com/aallan/vera/issues/819)): reject-set membership (opaque built-ins in, overridable combinators out), top-level + `where`-block `fn ` definitions flagged, non-built-in / overridable / prose-mention ignored, and the shipped docs are currently clean | | `test_runtime_traps.py` | 98 | 3,255 | Runtime trap categorisation (#516 Stage 1), out-of-bounds host-read bounds check (#1145), stdout/stderr-on-trap preservation (#522), `IO.print` live tee (#543), and trap source backtrace (#516 Stage 2): `_classify_trap` per-`kind` mapping (`divide_by_zero`/`out_of_bounds`/`stack_exhausted`/`unreachable`/`overflow`/`contract_violation`/`unknown`), plus `host_error` from `_classify_host_error` on `execute()`'s non-`Trap` branch, `WasmTrapError` shape + `RuntimeError` substitutability, end-to-end `cmd_run` text + JSON envelopes including `trap_kind`, captured `stdout`, captured `stderr`, JSON-mode "no stderr leak" invariant, cross-stream code-order regression using merged `redirect_stdout`/`redirect_stderr`, the v0.0.123 tee suite (live streaming, write-count + order preservation, JSON-mode tee suppression, trap preservation invariant under tee, per-write flush count, default-execute silence), and the v0.0.124 source-mapping suite — `_resolve_trap_frames` unit tests covering user-fn / built-in / built-in-prefix / monomorphized base-name fallback / unknown-name / no-frames-attribute / leaf-first ordering preservation; end-to-end `cmd_run` text-mode + JSON-mode backtrace including the **leaf-first** ordering invariant; contract-violation backtrace in both text and JSON modes; direct `execute()` `WasmTrapError.frames` attachment; **suppression marker** for collapsed leading runtime-helper frames (mocked `vera.codegen.execute` with synthetic `is_builtin=True` leaf frames so the collapse logic is testable deterministically); source-map population for top-level fns + lifted closures (with span-value assertion against the closure literal's exact line range); and the no-builtin-leakage regression that pins built-in helpers (`alloc` / `gc_collect` / `contract_fail`) NOT being registered in `fn_source_map`; plus the v0.0.125 Stage 3 suite (`#547`) — text-mode `Fix:` block surfacing with position-ordering invariant (Fix appears after the source backtrace), text-mode block suppression for `contract_violation` (no empty header noise), JSON-mode `fix` field always-present (schema stability) including the empty-string case, `_TRAP_FIX_PARAGRAPHS` table-completeness assertion (every kind in the taxonomy has a Fix paragraph entry), and the column-wrap invariant (~76 chars max per line, two-space indent under the `Fix:` heading); plus the UTF-8 hardening suite **`TestHostPrintInvalidUtf8589`** (`#589` / `#592`) — after `#592` centralised the `errors="replace"` invariant into the single `vera.runtime.text.safe_utf8_decode` helper — reached only through a shared `_slice_and_decode` helper (`vera/runtime/heap.py`) that the three WASM-memory string readers (`_read_wasm_string` and `_read_string_export` there, and `vera/wasm/markdown.py::_read_string`) delegate to, with the `host_print` / `host_stderr` / `host_contract_fail` host imports and the String-return extractor in `execute()` routing through those readers rather than decoding inline: one helper unit test pinning the invariant once (invalid bytes → U+FFFD, valid + empty pass through), three wire-real end-to-end tests that drive the **production** readers (`_read_wasm_string` / markdown `_read_string` behind a synthetic-WAT `probe` host import; `_read_string_export` against a real exported memory, also covering its out-of-bounds → `None` pointer-fallback) over a region seeded with invalid UTF-8 — so a strict-decode regression surfaces as a `UnicodeDecodeError` escaping wasmtime's trampoline, and the host imports / extractor are transitively covered — and one synthetic-WAT end-to-end test that imports `vera.print` and calls it with raw invalid UTF-8 bytes to pin the wasmtime-trampoline fact independently (a Python `UnicodeDecodeError` inside a host import escapes as a "python exception" cause iff the host decode is strict); the six pre-`#592` structural source-grep assertions were retired by the centralisation; plus the Ctrl-C-during-host-import suite **`TestHostSleepKeyboardInterrupt`** ([#595](https://github.com/aallan/vera/issues/595) / [#599](https://github.com/aallan/vera/issues/599)) — after the v0.0.160 relocation to a single `except KeyboardInterrupt` handler in `execute()` (enabled by `wasmtime>=45.0.0`'s `except BaseException` trampoline fix): one structural assertion that the four per-host-import `raise _VeraExit(130)` guards are gone and the centralized handler maps to `exit_code=130`, plus four end-to-end tests that compile real Vera programs calling `IO.sleep(...)`, `IO.read_char(())`, a mocked fused `await` ([#841](https://github.com/aallan/vera/issues/841) — `Future.result()` patched to interrupt), and a live in-flight fused `await` (no mocking — `_thread.interrupt_main()` fired only once the server confirms the request arrived, handler then released so the executor teardown has a real worker to wait out; post-[#848](https://github.com/aallan/vera/issues/848) the progress print precedes the `async(...)`, so program order makes its stdout assertion deterministic), raise `KeyboardInterrupt` from inside the blocking call, and assert the program exits with `ExecuteResult.exit_code == 130` (pre-interrupt stdout preserved) instead of a raw Python traceback escaping wasmtime's trampoline; plus the host-callback surface suite **`TestHostCallbackErrorSurface1302`** / **`TestClassifyHostError1302`** ([#1302](https://github.com/aallan/vera/issues/1302)) — `execute()` classified on the exception's TYPE NAME (`Trap` / `WasmtimeError`), so a host import raising an ordinary Python exception skipped the conversion and escaped as a 63-line interpreter traceback with the captured streams dropped, and in `--json` mode with no envelope emitted at all. The conversion is now keyed on the BOUNDARY (everything escaping the guest invocation), and the suite drives a real `json_stringify(JNumber(nan()))` program through all three surfaces: `execute()` raising a `WasmTrapError` of `kind="host_error"` carrying the host's sentence, the pre-failure stdout and the original exception as `__cause__`; text-mode `cmd_run` asserted on the ABSENCE of `Traceback` / `File "` / `wasmtime` and a sub-ten-line diagnostic, since asserting only that the sentence appears would still pass on the pre-fix output where it was the traceback's last line; and JSON-mode `cmd_run` producing a parseable envelope with `trap_kind`, an always-present empty `fix`, `frames`, and the captured `stdout`. **`TestHostErrorDebugKnob1302`** covers the escape hatch the conversion needs — `VERA_DEBUG_HOST_ERRORS` (ENVIRONMENT.md) re-raises the original exception so a binding bug stays diagnosable — as a deliberate pair, one test proving the knob does something and one proving its absence is what produces the one-liner, since neither alone distinguishes a working knob from unconditional behaviour, plus the truthiness table shared with `VERA_EAGER_GC` and an end-to-end `cmd_run` case | | `test_serve.py` | 8 | 189 | #305 `vera serve` driver end-to-end: GET/POST echo round-trips (method/path/headers/body cross the host↔guest boundary via `build_request_adt` / `decode_response_adt`), handler status propagation, runtime contract violation → 500 with `trap_kind` JSON, `State` isolation across requests (instance-per-request pinned), and clean `make_server` validation errors (missing / wrong-signature `handle`), and an eager-GC round-trip pinning the Request builder's shadow-rooting; all on ephemeral ports | -| `test_wasi_target.py` | 274 | 2,142 | #237 WASI Preview 2 target (spec chapter 13): component emission validated live against the real wasmtime host — parse (`Component(engine, wat)`), instantiate (`Linker.add_wasip2()` + `WasiConfig`), and execute (stdout/stderr capture, env, argv incl. a 500-arg GC-pressure stress and a >64 KiB arena-cap trap, preopen file round-trips + errno mapping, stdin incl. UTF-8 multibyte, clocks, random bounds, exit, contract-violation text on WASI stderr, overflow); the family gate (clean diagnostic naming unsupported families, never a silent fallback); the core-emission pin (default `--target wasm` WAT untouched); `cmd_compile`/`cmd_run --target wasi-p2` CLI integration (binary component artifact, `--wat` component text, JSON envelopes, trap-kind classification through the component boundary, exit-code 0/1 degradation, `--fn` rejection); the `execute_wasi_p2` host runner (env passthrough, argv, stderr capture, String-main `wasi:cli/run` fallback); the **dual-target conformance differential** (all 173 run-level conformance programs driven under both targets, byte-identical stdout/stderr required — 121 are dual-tested and 52 skip *loudly* rather than passing silently: 45 whose compiled WAT imports a host family outside `IO`/`Random` (`state`, `map`, `json`, `set`, `decimal`, `html`, `md`, `regex`, `db`), 6 with no public zero-argument `main`, and 1 calling a nondeterministic op. The excluded set is defined by those three properties rather than by a filename list, so it stays accurate as programs are added); a stock-`wasmtime`-CLI smoke test (skips when the CLI is not installed); and the Stage-D **server world** (`world="server"`): incoming-handler emission pins (adapter lift, 32-slot dispatch table, @0.2.0 version pin, no `wasi:cli/run`), #305 handler validation + server family-gate diagnostics (rejected IO ops, non-String map instantiations, unsupported families), the cli-world pin (default emission carries no server machinery), Request/Response layout tripwires, and a stock-`wasmtime serve` smoke battery (host-vs-served differential over a method/path/header/body matrix incl. duplicate-header later-wins, in-guest map-op order parity, `IO.print` console routing, trap→500 with symbolized backtrace + violation text, graceful 500s for forbidden headers and out-of-range status, a 1 MiB GC-stress echo, and an eager-GC shadow-push mutation validation; skips when the CLI is not installed) | +| `test_wasi_target.py` | 275 | 2,142 | #237 WASI Preview 2 target (spec chapter 13): component emission validated live against the real wasmtime host — parse (`Component(engine, wat)`), instantiate (`Linker.add_wasip2()` + `WasiConfig`), and execute (stdout/stderr capture, env, argv incl. a 500-arg GC-pressure stress and a >64 KiB arena-cap trap, preopen file round-trips + errno mapping, stdin incl. UTF-8 multibyte, clocks, random bounds, exit, contract-violation text on WASI stderr, overflow); the family gate (clean diagnostic naming unsupported families, never a silent fallback); the core-emission pin (default `--target wasm` WAT untouched); `cmd_compile`/`cmd_run --target wasi-p2` CLI integration (binary component artifact, `--wat` component text, JSON envelopes, trap-kind classification through the component boundary, exit-code 0/1 degradation, `--fn` rejection); the `execute_wasi_p2` host runner (env passthrough, argv, stderr capture, String-main `wasi:cli/run` fallback); the **dual-target conformance differential** (all 174 run-level conformance programs driven under both targets, byte-identical stdout/stderr required — 122 are dual-tested and 52 skip *loudly* rather than passing silently: 45 whose compiled WAT imports a host family outside `IO`/`Random` (`state`, `map`, `json`, `set`, `decimal`, `html`, `md`, `regex`, `db`), 6 with no public zero-argument `main`, and 1 calling a nondeterministic op. The excluded set is defined by those three properties rather than by a filename list, so it stays accurate as programs are added); a stock-`wasmtime`-CLI smoke test (skips when the CLI is not installed); and the Stage-D **server world** (`world="server"`): incoming-handler emission pins (adapter lift, 32-slot dispatch table, @0.2.0 version pin, no `wasi:cli/run`), #305 handler validation + server family-gate diagnostics (rejected IO ops, non-String map instantiations, unsupported families), the cli-world pin (default emission carries no server machinery), Request/Response layout tripwires, and a stock-`wasmtime serve` smoke battery (host-vs-served differential over a method/path/header/body matrix incl. duplicate-header later-wins, in-guest map-op order parity, `IO.print` console routing, trap→500 with symbolized backtrace + violation text, graceful 500s for forbidden headers and out-of-range status, a 1 MiB GC-stress echo, and an eager-GC shadow-push mutation validation; skips when the CLI is not installed) | ## Conformance Suite -The conformance suite is a collection of 242 small, focused programs in `tests/conformance/` that systematically validate every language feature against the spec. Most programs are self-contained; the module-focused Chapter 8 cases use `import` statements where needed, and `ch07_cross_module_contracts.vera` still depends on `ch07_cross_module_contracts_lib.vera`. Each program tests one feature or a small group of related features. +The conformance suite is a collection of 243 small, focused programs in `tests/conformance/` that systematically validate every language feature against the spec. Most programs are self-contained; the module-focused Chapter 8 cases use `import` statements where needed, and `ch07_cross_module_contracts.vera` still depends on `ch07_cross_module_contracts_lib.vera`. Each program tests one feature or a small group of related features. Simon Willison [argues](https://simonwillison.net/tags/conformance-suites/) that conformance suites are a "huge unlock" for language projects — they transform development from trust-based to verification-based. The conformance suite serves as the definitive specification artifact that any implementation (or agent) can validate against. @@ -263,7 +263,7 @@ Each conformance program declares the deepest pipeline stage it must pass: | `parse` | Source text is syntactically valid | 0 | | `check` | Parses and type-checks cleanly | 49 | | `verify` | Type-checks and all contracts verified by Z3 | 20 | -| `run` | Compiles to WASM and executes correctly | 173 | +| `run` | Compiles to WASM and executes correctly | 174 | Almost all programs are at the `run` level — they compile and execute, producing correct results. Forty-nine programs (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch03_typed_holes`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_cross_module_contracts_lib`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch07_bare_effect_op_rejected`, `ch08_ambiguous_import_adt_lib_bool`, `ch08_ambiguous_import_adt_lib_int`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_lib_bool`, `ch08_ambiguous_import_lib_int`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_cross_module_generic_lib`, `ch08_module_generic_diamond_base`, `ch08_module_prelude_adt_contention_rejected`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_transitive_module_import_base`, `ch08_visibility_private`, `ch08_xmod_widen_lib`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_eq_non_derivable_rejected`, `ch09_http`, `ch09_inference`, `ch09_ord_adt_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) are at the `check` level. Thirty-six of them — `ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch07_bare_effect_op_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, and `ch02_alias_cycle_rejected` — are **negative tests** that assert a specific diagnostic (E206, E135, E183, E201, E127, E153, E153, E153, E130, E130, E174, E182, E217, E156, E156, E155, E155, E011, E154, E154, E154, E154, E154, E154, E150, E152, E151, E242, E243, E207, E208, E208, E209, E128, E336, and E132 respectively) via the manifest's `expected_error` field. One more — `ch08_module_prelude_adt_contention_rejected` — is a negative at the `compile` stage rather than at `check`: it carries `expected_error_stage: "compile"` beside `expected_error: E621`, so the harness asserts it type-checks CLEANLY and is then refused by `vera compile` with that code, which is the property a codegen-phase diagnostic exists for. `ch09_http` and `ch09_inference` are environment-gated (network / API key). Twenty programs (`ch03_slot_let_chains`, `ch03_slot_noncommutative`, `ch04_nested_option_ctor`, `ch04_primitive_obligations`, `ch05_apply_fn_typing`, `ch06_adt_sort_disambiguation`, `ch07_cross_module_contracts`, `ch07_invisible_import_op_name_lib`, `ch07_io_read_char`, `ch07_io_sleep`, `ch07_random_effect`, `ch08_state_alias_module_table_lib`, `ch08_module_generic_diamond_mid1`, `ch08_module_generic_diamond_mid2`, `ch08_state_alias_per_module_lib`, `ch08_transitive_module_import_mid`, `ch09_http_server`, `ch09_invisible_import_ability_op_lib`, `ch09_math_builtins`, `ch09_nested_helper_family_op_name_lib`) are at the `verify` level, using Z3-provable contracts — a library module is pinned at the deepest level it reaches, so the two per-module alias-table libraries are verified rather than only checked. @@ -409,7 +409,7 @@ tests/conformance/ ├── ch01_int_literals.vera # Chapter 1: Integer literals ├── ch01_float_literals.vera # Chapter 1: Float64 literals ├── ch01_string_escapes.vera # Chapter 1: String escape sequences -├── ... # 242 programs total, organized by spec chapter +├── ... # 243 programs total, organized by spec chapter ├── ch07_state_handler.vera # Chapter 7: State effect handler ├── ch07_exn_handler.vera # Chapter 7: Exn effect handler ├── ch09_numeric_builtins.vera # Chapter 9: Numeric built-in functions @@ -443,7 +443,7 @@ The manifest is the machine-readable feature inventory — agents can query it t ### Running the conformance suite ```bash -# Via pytest (parametrized — 1,210 tests: five stages × 242 entries) +# Via pytest (parametrized — 1,215 tests: five stages × 243 entries) pytest tests/test_conformance.py -v # Via standalone script (used in CI and pre-commit) @@ -972,9 +972,9 @@ Twenty-nine scripts in `scripts/` validate cross-cutting concerns beyond unit te | Script | What it validates | |--------|-------------------| -| `check_conformance.py` | All 242 conformance entries hold at their declared level (parse/check/verify/run) — positives pass; the negatives fail at the stage their `expected_error_stage` names (`check` by default, or `compile`, which also asserts the program type-checks cleanly) with their `expected_error` E-code | +| `check_conformance.py` | All 243 conformance entries hold at their declared level (parse/check/verify/run) — positives pass; the negatives fail at the stage their `expected_error_stage` names (`check` by default, or `compile`, which also asserts the program type-checks cleanly) with their `expected_error` E-code | | `check_examples.py` | All 42 `.vera` examples pass `vera check` + `vera verify` | -| `check_corpus_canonical.py` | All 291 corpus programs (recursive over `examples/` + `tests/conformance/`) are in canonical form under `vera fmt` | +| `check_corpus_canonical.py` | All 292 corpus programs (recursive over `examples/` + `tests/conformance/`) are in canonical form under `vera fmt` | | `check_examples_readme.py` | Every `vera run` command in examples/README.md references an existing file and exported function | | `check_spec_examples.py` | 189 parseable code blocks from spec chapters: parse, type-check, and verify | | `check_readme_examples.py` | All Vera code blocks in README.md parse correctly | @@ -1081,9 +1081,9 @@ The repository configures 36 hooks across two stages: 34 run at the commit stage | `ruff check .` | Lint Python with ruff (default `F` + `E` rules) | | `mypy vera/` | Type-check compiler in strict mode | | `pytest tests/ -q` | Run full test suite | -| `check_conformance.py` | All 242 conformance entries hold at their declared level — positives pass; negatives fail at the stage their `expected_error_stage` names (`check` or `compile`) with their `expected_error` E-code | +| `check_conformance.py` | All 243 conformance entries hold at their declared level — positives pass; negatives fail at the stage their `expected_error_stage` names (`check` or `compile`) with their `expected_error` E-code | | `check_examples.py` | All 42 examples pass `vera check` + `vera verify` | -| `check_corpus_canonical.py` | All 291 `examples/` + `tests/conformance/` programs (recursive) are in canonical form (`vera fmt`) | +| `check_corpus_canonical.py` | All 292 `examples/` + `tests/conformance/` programs (recursive) are in canonical form (`vera fmt`) | | `check_examples_readme.py` | `vera run` commands in `examples/README.md` reference existing files and exported functions | | `check_readme_examples.py` | README code blocks parse correctly | | `check_examples_doc.py` | EXAMPLES.md code blocks parse correctly | diff --git a/docs/SKILL.md b/docs/SKILL.md index c11ce0c0..3d801e1f 100644 --- a/docs/SKILL.md +++ b/docs/SKILL.md @@ -2402,7 +2402,7 @@ public fn main(@Unit -> @Unit) ## Conformance Suite -The `tests/conformance/` directory contains 242 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. +The `tests/conformance/` directory contains 243 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. Each program is organized by spec chapter (`ch01_int_literals.vera`, `ch04_match_basic.vera`, `ch07_state_handler.vera`, etc.) and the `manifest.json` file maps features to programs. When you need to see how a specific construct works, check the conformance program before reading the spec. diff --git a/docs/index.html b/docs/index.html index fd324e1f..08726d78 100644 --- a/docs/index.html +++ b/docs/index.html @@ -699,7 +699,7 @@

This page is also a machine-readable specificati Vera is under active development

- A complete compiler with 164 built-in functions, ten algebraic effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge), contract-driven testing via Z3, a language server with agent-facing proof deltas, and a 14-chapter specification. A 242-program conformance suite and 42 worked examples are validated against the spec on every pull request. All of it is developed openly on GitHub and released under the MIT licence. + A complete compiler with 164 built-in functions, ten algebraic effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge), contract-driven testing via Z3, a language server with agent-facing proof deltas, and a 14-chapter specification. A 243-program conformance suite and 42 worked examples are validated against the spec on every pull request. All of it is developed openly on GitHub and released under the MIT licence.

diff --git a/docs/index.md b/docs/index.md index 49454d5b..f779ffdf 100644 --- a/docs/index.md +++ b/docs/index.md @@ -281,7 +281,7 @@ For other models: point them at [`SKILL.md`](https://veralang.dev/SKILL.md) via ## Status -Vera is under [active development](https://raw.githubusercontent.com/aallan/vera/main/ROADMAP.md). A complete compiler with 164 built-in functions, ten algebraic effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge), contract-driven testing via [Z3](https://www.microsoft.com/en-us/research/project/z3-3/), and a 14-chapter specification. A 242-program conformance suite and 42 worked examples are validated against the spec on every pull request. All of it is developed openly on [GitHub](https://github.com/aallan/vera) and released under the MIT licence. +Vera is under [active development](https://raw.githubusercontent.com/aallan/vera/main/ROADMAP.md). A complete compiler with 164 built-in functions, ten algebraic effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge), contract-driven testing via [Z3](https://www.microsoft.com/en-us/research/project/z3-3/), and a 14-chapter specification. A 243-program conformance suite and 42 worked examples are validated against the spec on every pull request. All of it is developed openly on [GitHub](https://github.com/aallan/vera) and released under the MIT licence. ## Links diff --git a/docs/llms-full.txt b/docs/llms-full.txt index a440350b..7c7d4184 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -2408,7 +2408,7 @@ public fn main(@Unit -> @Unit) ## Conformance Suite -The `tests/conformance/` directory contains 242 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. +The `tests/conformance/` directory contains 243 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. Each program is organized by spec chapter (`ch01_int_literals.vera`, `ch04_match_basic.vera`, `ch07_state_handler.vera`, etc.) and the `manifest.json` file maps features to programs. When you need to see how a specific construct works, check the conformance program before reading the spec. @@ -2487,7 +2487,7 @@ Read `SKILL.md` for the full language reference. It covers syntax, slot referenc ### Conformance programs as reference -The conformance suite in `tests/conformance/` contains 242 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-seven negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) instead must *fail* with the E-code in their `expected_error` field, at the stage their `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. +The conformance suite in `tests/conformance/` contains 243 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-seven negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) instead must *fail* with the E-code in their `expected_error` field, at the stage their `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. ### Workflow @@ -2666,9 +2666,9 @@ Each stage is a module with a single public API function (`parse_file`, `transfo pytest tests/ -v # Run all tests (see TESTING.md) pytest tests/test_conformance.py -v # Conformance suite only mypy vera/ # Type-check the compiler -python scripts/check_conformance.py # All 242 conformance programs hold (positives pass; negatives fail with their E-code) +python scripts/check_conformance.py # All 243 conformance programs hold (positives pass; negatives fail with their E-code) python scripts/check_examples.py # All 42 examples must pass -python scripts/check_corpus_canonical.py # All 291 corpus programs in canonical form +python scripts/check_corpus_canonical.py # All 292 corpus programs in canonical form ``` Test helpers follow a pattern: `_check_ok(source)` / `_check_err(source, match)` / `_verify_ok(source)` / `_verify_err(source, match)`. See existing tests for examples. @@ -2677,7 +2677,7 @@ When implementing a new language feature, write the conformance program *first* ### Invariants -- All 242 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first +- All 243 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first - All 42 examples in `examples/` must pass `vera check` and `vera verify` - `mypy vera/` must be clean - `pytest tests/ -v` must pass @@ -3222,7 +3222,7 @@ None of this is Vera-specific, but it validates the design choices. The thesis i This is a real concern. LLMs are trained on trillions of tokens of Python, TypeScript, and JavaScript. A MojoBench study (NAACL 2025) found that even fine-tuned models achieved only 30–35% improvement over base models on Mojo code generation, illustrating the cold-start problem for new languages. -Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (242 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification. +Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (243 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification. ## How does Vera compare to Dafny / Lean / Koka / F*? @@ -3265,7 +3265,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 11,633 tests, including a 242-program conformance suite +- 11,670 tests, including a 243-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/docs/llms.txt b/docs/llms.txt index 2447d760..afb6e578 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -56,4 +56,4 @@ Current version: 0.1.11. The reference compiler is written in Python. Install th - [TESTING.md](https://raw.githubusercontent.com/aallan/vera/main/TESTING.md): Test suite architecture, coverage data, and test conventions. - [KNOWN_ISSUES.md](https://raw.githubusercontent.com/aallan/vera/main/KNOWN_ISSUES.md): Known bugs and limitations. - [CONTRIBUTING.md](https://raw.githubusercontent.com/aallan/vera/main/CONTRIBUTING.md): Contribution guidelines. -- [Conformance Suite](https://github.com/aallan/vera/tree/main/tests/conformance): 242 programs validating every language feature against the spec. +- [Conformance Suite](https://github.com/aallan/vera/tree/main/tests/conformance): 243 programs validating every language feature against the spec. diff --git a/tests/test_infer_vera_type_join_1286.py b/tests/test_infer_vera_type_join_1286.py index 190f2eb7..483baf54 100644 --- a/tests/test_infer_vera_type_join_1286.py +++ b/tests/test_infer_vera_type_join_1286.py @@ -51,13 +51,14 @@ structurally parallel, arm for arm, which is this fix's core claim. One shape found by the same sweep is NOT closed here and is deliberately left -loud: an `IndexExpr` argument (`idg(@Array.0[1])`) dangles the same way, -but the rewrite's arm delegates to `_infer_index_element_type`, which resolves -chained indexing, aliases and `Future` payloads against codegen tables the -monomorphizer does not have. A partial mirror would answer differently from the -rewrite for those cases — trading a shape where both consultors say "unknown" -for one where they disagree, which is the worse failure — so it wants its own -change rather than a line here. +loud, tracked as #1327: an `IndexExpr` argument (`idg(@Array.0[1])`) +dangles the same way, but the rewrite's arm delegates to +`_infer_index_element_type`, which resolves chained indexing, aliases and +`Future` payloads against codegen tables the monomorphizer does not have. A +partial mirror would answer differently from the rewrite for those cases — +trading a shape where both consultors say "unknown" for one where they +disagree, which is the worse failure — so it wants its own change rather than a +line here. """ from __future__ import annotations diff --git a/vera/README.md b/vera/README.md index 90b1f754..2be365f7 100644 --- a/vera/README.md +++ b/vera/README.md @@ -753,7 +753,7 @@ The `ERROR_CODES` dict in `errors.py` maps every code to a short description (16 ## Test Suite -Testing spans a **pytest suite** of 11,633 tests across 173 files — compiler-internals unit tests plus a **conformance suite** (242 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. +Testing spans a **pytest suite** of 11,670 tests across 174 files — compiler-internals unit tests plus a **conformance suite** (243 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. See **[TESTING.md](../TESTING.md)** for the comprehensive testing reference -- test file table, conformance suite details, compiler code coverage, language feature coverage, helper conventions, validation scripts, CI pipeline, and guidelines for adding tests. From 21c140ca404d2ed28be0efa119e68e40ac05937a Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 22:23:57 +0100 Subject: [PATCH 53/69] Enforce spec 1.4's reserved-keyword MUST, from the grammar Spec 1.4 says its keywords must not be used as function names. E153 held that for eleven of them; for twenty-one it held nothing, and those names were not traps -- `private fn with(@Int -> @Int)` declared, type checked, verified, compiled, ran, and answered a bare `with(1)`, and kept working inside a contract clause, inside an if/then/else, in a function carrying its own `where` block, and after a `let`. The comment above `_KEYWORD_FN_NAMES` gave the opposite as the reason they were absent from the set, so the omission rested on a premise the tree refutes. What was broken was the specification, not any program: a model trusting 1.4 and a model trusting the compiler derive different programs from one source of truth, with no tool contradicting either. DESIGN principle 1 makes that a defect whatever the program does at runtime, principle 6 chooses enforcement over narrowing 1.4, and principle 3 supplies the precedent -- E152 rejects even a faithful re-declaration of a built-in effect, a second textual spelling being itself the problem. The reserved set is now derived from `vera/grammar.lark`, the shape `builtin_effect_names()` already uses for E152, so a keyword added to the grammar is reserved the moment it is added; the hand-list it replaces had fallen twenty-one names behind with no gate able to see the drift. The derivation found four more -- `ability`, `effects`, `op` and `result` are grammar keywords 1.4 never listed -- and 1.4's list is reconciled to it. They join E153 as a fourth branch with its own rationale. The existing keyword wording asserts no call site can reach the declaration, which is false for all of these, so reusing it would tell authors a falsehood about their own program; the new branch argues from the reservation, and carries a per-name rename hint because the generic template yields `in_fn` and `type_fn`. `handle` stays legal as the host-invoked entry point, and the reservation stays on the whole identifier. Section 1.4's "type names" half is corrected rather than enforced: every type-namespace binder in the grammar is an UPPER_IDENT and every keyword is lowercase, so that half was never violable. New conformance negative ch05_reserved_contextual_keyword_fn_rejected plus 110 tests -- five parametrized batteries over all 21 (declaration, visibility, where-helper, rationale-free-of-the-false-claim, usable fix) with `handle` and fifteen keyword-containing names as controls. Red first: 107 of the 110 failed at the branch point, the three passers being the controls. Mutation-validated by dropping one keyword from the derivation, which flips that name's five cells and both set pins red while the other twenty stay green. Corpus differential: zero movers. Two documentation corrections from PR #1326's review ride here, both in files this commit already touches. FAQ.md's browser passage names `IO.read_char` separately from the by-construction boundary list rather than folding it in: the browser stub returns an explanatory Err reading "IO.read_char not yet supported in browser target", pending the JSPI suspend/resume primitive, so it is a not-yet rather than something a page cannot host. vera/README.md scopes the browser runtime's "any compiled Vera .wasm module" claim to core modules -- the default and browser targets emit the same binary and so share one import ABI, while --target wasi-p2 emits a component, a different artifact format with its own host. Closes #1296 Co-Authored-By: Claude --- AGENTS.md | 8 +- CHANGELOG.md | 2 + CLAUDE.md | 8 +- FAQ.md | 6 +- KNOWN_ISSUES.md | 1 - README.md | 2 +- ROADMAP.md | 2 +- SKILL.md | 2 +- TESTING.md | 34 +- docs/SKILL.md | 2 +- docs/index.html | 2 +- docs/index.md | 2 +- docs/llms-full.txt | 16 +- docs/llms.txt | 2 +- spec/01-lexical-structure.md | 13 +- spec/05-functions.md | 8 +- ...served_contextual_keyword_fn_rejected.vera | 27 ++ tests/conformance/manifest.json | 14 + tests/test_checker_modules.py | 318 +++++++++++++++++- vera/README.md | 6 +- vera/checker/registration.py | 167 +++++++-- 21 files changed, 561 insertions(+), 81 deletions(-) create mode 100644 tests/conformance/ch05_reserved_contextual_keyword_fn_rejected.vera diff --git a/AGENTS.md b/AGENTS.md index dc29f275..f0f8b9dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Read `SKILL.md` for the full language reference. It covers syntax, slot referenc ### Conformance programs as reference -The conformance suite in `tests/conformance/` contains 243 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-seven negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) instead must *fail* with the E-code in their `expected_error` field, at the stage their `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. +The conformance suite in `tests/conformance/` contains 244 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-eight negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_contextual_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) instead must *fail* with the E-code in their `expected_error` field, at the stage their `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. ### Workflow @@ -187,9 +187,9 @@ Each stage is a module with a single public API function (`parse_file`, `transfo pytest tests/ -v # Run all tests (see TESTING.md) pytest tests/test_conformance.py -v # Conformance suite only mypy vera/ # Type-check the compiler -python scripts/check_conformance.py # All 243 conformance programs hold (positives pass; negatives fail with their E-code) +python scripts/check_conformance.py # All 244 conformance programs hold (positives pass; negatives fail with their E-code) python scripts/check_examples.py # All 42 examples must pass -python scripts/check_corpus_canonical.py # All 292 corpus programs in canonical form +python scripts/check_corpus_canonical.py # All 293 corpus programs in canonical form ``` Test helpers follow a pattern: `_check_ok(source)` / `_check_err(source, match)` / `_verify_ok(source)` / `_verify_err(source, match)`. See existing tests for examples. @@ -198,7 +198,7 @@ When implementing a new language feature, write the conformance program *first* ### Invariants -- All 243 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first +- All 244 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_contextual_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first - All 42 examples in `examples/` must pass `vera check` and `vera verify` - `mypy vera/` must be clean - `pytest tests/ -v` must pass diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a3e83ce..e631af3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **Spec §1.4's reserved-keyword MUST is now enforced, for twenty-one names that nothing held** ([#1296](https://github.com/aallan/vera/issues/1296)). `§1.4` says its keywords must not be used as function names; `E153` held that for eleven of them, and `private fn with(@Int -> @Int)` — with `then`, `else`, `data`, `type`, `module`, `import`, `public`, `private`, `requires`, `ensures`, `invariant`, `decreases`, `effect`, `in`, `where` and `pure` — declared, type-checked, verified, compiled, ran and round-tripped `vera fmt`. They were not traps: a bare `with(1)` resolved to the declaration and returned its value, and stayed working inside a contract clause, inside an `if`/`then`/`else`, in a function carrying its own `where { }` block, and after a `let`. The comment above `_KEYWORD_FN_NAMES` gave the opposite as the reason they were absent from the set — that the contextual lexer "does not admit them as a function name, so no declaration reaches this checker at all" — so the omission rested on a premise the tree refuted, and the divergence was between the specification and the implementation rather than in any program's behaviour: a model trusting §1.4 and a model trusting the compiler derived different programs from one source of truth, with no tool contradicting either. DESIGN principle 1 (checkability) makes an unenforced MUST a defect whatever the program does at runtime, principle 6 (fewer valid programs) chooses enforcement over narrowing §1.4, and principle 3 supplies the precedent — `E152` rejects even a *faithful* re-declaration of a built-in effect, because a second textual spelling is itself the problem. The reserved set is now **derived from `vera/grammar.lark`** rather than hand-listed, the shape `builtin_effect_names()` already uses for `E152`, so a keyword added to the grammar is reserved the moment it is added; the hand-list this replaces had fallen twenty-one names behind the grammar with no gate able to see the drift. The derivation is what found the other four: `ability`, `effects`, `op` and `result` are grammar keywords §1.4 never listed and were accepted as function names on the same footing, and §1.4's list is reconciled to the grammar (gaining those four plus `old` and `new`, which `E153` already reserved). They join `E153` as a **fourth** branch with its own rationale: the existing keyword wording asserts that no call site can reach the declaration, which is false for every one of these names, so reusing it would have told authors a falsehood about their own program — the new branch argues from the reservation instead, and carries a per-name rename suggestion because the generic `_fn` template produces `in_fn` / `type_fn` / `pure_fn`. `handle` stays legal, carved out as the host-invoked `vera serve` / `wasi:http` entry point; the reservation remains on the whole identifier, so `older`, `with_it` and `then_value` are ordinary names. §1.4's "type names" half is corrected rather than enforced: every type-namespace binder in the grammar is an `UPPER_IDENT` and every keyword is lowercase, so that half was never violable. New conformance negative `ch05_reserved_contextual_keyword_fn_rejected` plus 110 tests in `tests/test_checker_modules.py` — five parametrized batteries over all 21 (declaration, visibility, `where`-helper, rationale-free-of-the-false-claim, and a usable fix suggestion) with `handle` and fifteen keyword-containing names as controls; mutation-validated by dropping one keyword from the derivation, which flips that name's five cells and both set pins red while the other twenty stay green. Corpus differential: zero movers, no program in `examples/` or `tests/conformance/` having used such a name. + - **Two imports supplying one bare name are refused, in every namespace** ([#1304](https://github.com/aallan/vera/issues/1304)). Spec §8.5 ordered a local declaration against an import (§8.5.2) and gave the module-qualified form for reaching what a clash hides (§8.5.3), but defined no order between two *imports* that both supply one name. Neither did the implementation, and the gap was reachable: a module importing two dependencies that each export `forall fn gen` — one returning `@Int`, one `@Bool` — bound its bare call to whichever supplier a set of module paths happened to yield first, so one unchanged file was `vera check`-green on one run and `[E121] body has type Bool` on the next. Measured at the branch point across eight consecutive runs and eight hash seeds: accepted on seeds 0, 2 and 3, rejected on 1, 4, 5, 6 and 7, with the winner tracking module-name hash order rather than which import is written first. Codegen's E608 rail caught the *entry-visible* pair before it could matter there; the flap lived in the shapes the rail only reached at compile, from inside a module the entry program merely imports. Spec §8.5.2.2 now states the rule — a program **MUST NOT** leave a namespace with two imports supplying one bare function name — and the checker enforces it as **E155**, a check-phase code for a scope question that had been enforced by a codegen rail at the wrong layer. Refusing is what removes the flap rather than merely labelling it: with no pick to make, there is no iteration order left to expose, which a deterministic first-wins order would not have achieved (it would make the resolved declaration implicit in import sequence, §0.2.2, and let a library *adding* an export silently rebind a downstream bare call). The refusal is **definition-gated**, matching the rail it generalises: it fires because the import pair exists, not because a body names it, so an entry program importing two suppliers and never calling either is refused exactly as E608 already refused it, and rewriting a bare call in module-qualified form does not lift it. Two shapes clear it, both exercised through to their runtime value: a **local declaration** of the name (§8.5.2 — every bare call is then the local one, and each import stays reachable through `dep::name(...)`), or a **selective import** narrowing the other module's list. The ambiguity predicate is the one `namespace_fn_names` already derived for #1281 and #1299, now exposed per namespace as well as unioned, so the layer that refuses early and the layer that backstops it cannot disagree about which shape is ambiguous; the E608 condition keeps its cell, driven through a door that bypasses the checker. An ambiguous name is not injected into the type environment at all — reporting the clash while binding one supplier would leave the follow-on diagnostics keyed to whichever module the injection loop reached first, which is the nondeterminism the refusal exists to remove — so a bare call to it misses with an ordinary `E200` instead — an E-coded diagnostic emitted at **warning** severity, which the `--json` envelope reports in `warnings` rather than `diagnostics` and which does not fail the check on its own (measured: a program whose only diagnostic is `E200` reports `ok: true` and exits 0). The W-series is the separate `W001`/`W002` code namespace, and this is not one of them. **The data namespaces flapped the same way and are folded in.** Spec §8.5.4 gives constructor names the same shadowing rules as function names, which a function-only refusal would have made false: two modules each exporting a `public data Shape` with different constructor field types type-checked on some hash seeds and reported `[E213]` on others (accepted on seeds 2, 8, 9, 10 and 11; rejected on 0, 1, 3, 4, 5, 6 and 7), and the accepting seeds were the worse half — `check` **and** `verify` both passed, and the program died at `run` with an `E609` located at line 0 of the entry file, naming two modules the entry never imported. Data types are now **E156** and constructors **E157**, one code per declaration namespace exactly as codegen splits E608/E609/E610, and reported independently because they come apart: two modules exporting differently-named types that share a constructor name clash on the constructor alone. Their remedy differs from the function one and says so — E609/E610 refuse two modules' same-named data declarations by DECLARATION, consulting neither visibility nor the importer's filter nor local shadowing (the relaxation E608 received in [#1281](https://github.com/aallan/vera/issues/1281) has no data-side twin), so narrowing an import or shadowing the name locally leaves the program `E609` at compile. Both were measured against the fixture and both fail — as does marking one declaration `private` — so the two diagnostics prescribe renaming, and a cell pins that measurement so the fix text cannot drift into offering remedies that do not work. That rail over-breadth is now tracked as [#1317](https://github.com/aallan/vera/issues/1317). **A name the built-in registry already owns is not a clash** — the injection loops are `setdefault` over a `TypeEnv` the built-ins populate first, so a dependency exporting its own `option_map` never wins the bare name (measured as `E201` against the *prelude's* two-argument signature). The first cut of this refusal did not pass the built-in snapshot and reported two such dependencies as a clash, which was a new rejection rather than an earlier one; `namespace_fn_names`' claim that its ambiguity half is identical with or without the prelude argument was wrong for the same reason and is corrected, with the codegen call ordering it depends on now pinned by a cell. - **A `throw` payload is runtime-guarded, not only obligated** ([#1268](https://github.com/aallan/vera/issues/1268)). `throw(v)` narrows `v` into the `Exn` payload, and since the static half of this issue the narrowing carries the same obligation every other binding site does — but codegen emitted no guard, so the obligation's Tier-3 leg promised a runtime check that did not exist and an unverified `vera compile`/`run` delivered the violating value anyway. `throw(0 - 5)` under `effects(>)` ran to completion and returned **-5** through the `@Nat` payload; the refined spelling (`type Pos = { @Int | @Int.0 > 0 }`) did the same. Worse than a wrong answer: a handler clause binds the payload at its declared type, so the verifier hands every downstream consumer the invariant the payload just broke — a `@Nat`-taking function discharging `ensures(@Bool.result)` at Tier 1 from its parameter's type alone reported a **postcondition violation at run time on a postcondition `vera verify` had proved**. `throw` now takes the write boundary's guards at its op-call site, beside `put`'s ([#1203](https://github.com/aallan/vera/issues/1203)): the `@Int` -> `@Nat` sign guard, the `@Nat` -> `@Int` widening guard, and — refined FIRST, as at every other narrowing site — the §2.6.5 predicate guard for a refined payload, which traps through `$vera.contract_fail` naming the predicate that failed (`Refinement violation in throw(@Pos) / payload: @Int.0 > 0 failed`). The three arms mirror the verifier's own obligation triple one-for-one, so the obligation stream and the emitted guards stay in lock-step: the payload obligation is now `guarded` at all three arms and its Tier-3 leg is counted in `tier3_runtime` rather than disclosed as `tier3_unguarded`, and the refined arm's `guarded` claim is intersected with the same `_refined_boundary_codegen_guardable` test every other refined site uses, so an erased `@Unit` base or a nested refinement — which codegen emits no guard for — stays honestly unguarded. That mirror needed one repair to be true: it answered "guarded" for a refinement OVER a refinement, which `_refinement_guard_parts` refuses outright with a loud `E618` because the outer predicate alone would silently drop the inner membership — so `vera verify` exited 0 recording a Tier-3 runtime check for a program `vera compile` then refuses, a promise about a run that can never happen. It now bails on a refinement base, and the obligation discloses `tier3_unguarded` while `E618` still refuses. The same audit found the **qualified spelling recording something different from the bare one**: the `QualifiedCall` arm hardcoded `guarded=False` behind a comment stale since [#1203](https://github.com/aallan/vera/issues/1203), so `Exn.throw(v)` — which codegen lowers by synthesizing a bare node and delegating to the very dispatcher that emits the guards — disclosed `E504`/`E506` for a boundary that traps, and `State.put(v)` had been doing the same since #1203. Both now take the bare arm's rule on the same key (`op.parent_effect`), so the two spellings of one operation record identical statuses. The review of that fix found the arm had been hand-written as a refined-then-`@Nat` chain with **no widening branch at all**, so `State.put(@Nat.0)` / `Exn.throw(@Nat.0)` into an `@Int` cell recorded no obligation whatever while codegen emitted the `@Nat` -> `@Int` widening guard on both spellings — a guard the obligation stream never mentioned, the mirror image of the claim-without-a-guard this issue started from. It now routes through the shared `_obligate_binding_triple`, so the three arms cannot drift apart again by omission. The triple itself then turned out to be missing the [#820](https://github.com/aallan/vera/issues/820) INTERSECTION at these boundaries: its three arms are an `elif` chain, so a refinement OVER `@Int` claimed the value and the widening check never ran — and codegen mirrored that exactly, so both sides agreed to skip a check the UNREFINED spelling performs. A refinement predicate does not imply fit-in-i64, and `{ @Int | true }` is satisfied by the negative a `@Nat` above i64.MAX reinterprets to, so adding a refinement WEAKENED the boundary: `Exn` fed u64.MAX trapped on the widening guard while `Exn<{ @Int | true }>` fed the same value returned **-1**. The widening obligation and its guard now ride beside the refined pair rather than being replaced by it, and the two spellings trap alike; a user-declared effect's operation and `IO.sleep`'s `@Nat` formal stay the honest [#754](https://github.com/aallan/vera/issues/754) unguarded class. Two diagnostic rationales (`E504`, `E531`) that listed the `throw` payload among the unguarded sites — false once the guard landed, and contradicting the spec sentences this change amends — no longer do. Reaching the predicate needed the payload's TYPE, which neither of a cell's two names carries: `family` renders the predicate and `base` strips it, so `CellNames` now carries the type expression its producer already held rather than parsing one back out of a mangled family name. The predicate lowering itself is injected into the translation context (`set_refinement_guard_emitter`), because the two halves of a §2.6.5 guard sit on opposite sides of that seam — which local at what width is the context's question, while the trap message, the contract-fail import and the E617/E618 diagnostics are the generator's. An unrefined payload's WAT is byte-identical to before: a differential over all 278 pre-existing corpus programs — every `examples/` and `tests/conformance/` program, compiled and verified on both trees — moves nothing, in emitted WAT or in the obligation and diagnostic streams. diff --git a/CLAUDE.md b/CLAUDE.md index 9deeda26..442b878d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,10 +59,10 @@ VERA_EAGER_GC=1 vera run file.vera # Force GC on every alloc (see ENVIRONMENT.m VERA_DEBUG_HOST_ERRORS=1 vera run file.vera # Re-raise a host callback's own exception (see ENVIRONMENT.md, debug knob for host-binding bugs) mypy vera/ # Type-check the compiler itself -python scripts/check_conformance.py # Verify all 243 conformance programs (positives pass their level; negatives fail with their expected_error E-code) +python scripts/check_conformance.py # Verify all 244 conformance programs (positives pass their level; negatives fail with their expected_error E-code) python scripts/check_examples.py # Verify all 42 examples parse + check + verify python scripts/check_examples_run.py # Run every runnable example trap-free under the native runtime; the rest carry a documented skip property, and an example that is neither is an error -python scripts/check_corpus_canonical.py # Verify all 292 corpus programs are in canonical form (vera fmt) +python scripts/check_corpus_canonical.py # Verify all 293 corpus programs are in canonical form (vera fmt) python scripts/check_examples_readme.py # Verify vera run commands in examples/README.md python scripts/check_spec_examples.py # Verify spec code blocks parse python scripts/check_readme_examples.py # Verify README code blocks parse @@ -94,7 +94,7 @@ See [`TOOLCHAIN.md`](TOOLCHAIN.md) for the CLI cookbook — driving the toolchai - `vera/` — Reference compiler: grammar, parser, AST, transformer, type checker, verifier, codegen, CLI - `examples/` — 42 example Vera programs (all must pass `vera check` and `vera verify`) - `tests/` — Test suite (unit tests + conformance suite) -- `tests/conformance/` — 243 conformance programs validating every language feature against the spec +- `tests/conformance/` — 244 conformance programs validating every language feature against the spec - `scripts/` — CI and validation scripts ## Writing Vera code @@ -131,7 +131,7 @@ Before changing code — **adding or removing** — write the test that proves y ## What not to break - Pre-commit hooks run mypy + pytest + conformance suite + example validation on every commit -- All 243 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first +- All 244 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_contextual_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first - All 42 examples in `examples/` must pass `vera check` and `vera verify` - Version must stay in sync across `pyproject.toml`, `vera/__init__.py`, `docs/index.html`, `README.md`, and `uv.lock` (gated by `scripts/check_version_sync.py`); CHANGELOG.md must also carry a matching `## [X.Y.Z]` section - All tests must pass: `pytest tests/ -v` diff --git a/FAQ.md b/FAQ.md index 4a7e13a0..716dcd6d 100644 --- a/FAQ.md +++ b/FAQ.md @@ -170,7 +170,7 @@ vera compile --target browser examples/hello_world.vera # index.html ``` -Serve it with any HTTP server and open `index.html` — no build step, no bundler, no dependencies. The JavaScript runtime provides browser-appropriate implementations of the host bindings the browser target supports — the ones a page can host, which leaves a filesystem, an accept loop, a database and a model provider outside it by construction (spec §12.9.3 lists each and why): `IO.print` writes to the page, `IO.read_line` uses `prompt()`, and State, contracts, JSON serialization and Markdown rendering work identically to the wasmtime runtime. `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states — §9.7.1 and §9.7.3 — rather than by the two hosts happening to agree, which is what the parity suite checks them against. `json_parse` reaches it from the other side, by accepted domain rather than by output form: §9.7.1 states what it takes — RFC 8259-valid text that decodes to finite numbers and strings of Unicode scalar values — so the JavaScript constants (`NaN`, `Infinity`, `-Infinity`) and a lone-surrogate escape are `Err` at the parse on both hosts, with one message, and every text inside the domain parses identically. `md_parse` is the one operation on the shared surface still to reach parity: the two hand-written parsers disagree across nine measured classes of input the §9.7.3 subset leaves open, the largest by a wide margin being how a paragraph's plain-text runs are grouped — invisible to `md_render`, since the runs concatenate to the same text — and the rest render-visible, from how emphasis markers are scanned to block markers such as a `+` bullet or a list nested more than two deep. That one is tracked as [#1301](https://github.com/aallan/vera/issues/1301). +Serve it with any HTTP server and open `index.html` — no build step, no bundler, no dependencies. The JavaScript runtime provides browser-appropriate implementations of the host bindings the browser target supports — the ones a page can host, which leaves a filesystem, an accept loop, a database and a model provider outside it by construction (spec §12.9.3 lists each and why): `IO.print` writes to the page, `IO.read_line` uses `prompt()`, and State, contracts, JSON serialization and Markdown rendering work identically to the wasmtime runtime. `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states — §9.7.1 and §9.7.3 — rather than by the two hosts happening to agree, which is what the parity suite checks them against. `json_parse` reaches it from the other side, by accepted domain rather than by output form: §9.7.1 states what it takes — RFC 8259-valid text that decodes to finite numbers and strings of Unicode scalar values — so the JavaScript constants (`NaN`, `Infinity`, `-Infinity`) and a lone-surrogate escape are `Err` at the parse on both hosts, with one message, and every text inside the domain parses identically. `md_parse` is the one operation on the shared surface still to reach parity: the two hand-written parsers disagree across nine measured classes of input the §9.7.3 subset leaves open, the largest by a wide margin being how a paragraph's plain-text runs are grouped — invisible to `md_render`, since the runs concatenate to the same text — and the rest render-visible, from how emphasis markers are scanned to block markers such as a `+` bullet or a list nested more than two deep. That one is tracked as [#1301](https://github.com/aallan/vera/issues/1301). `IO.read_char` is separately not yet supported in the browser target at all, and is a not-yet rather than one of the boundaries above — a page could host it, and until the JSPI suspend/resume primitive it needs lands the stub returns an explanatory `Err` reading `IO.read_char not yet supported in browser target`. Two effects are refused outright rather than merely differing. `Inference` and `DB` return an explanatory `Err` from every operation in the browser, because the API key or database credential they would need is readable from page source and network traffic in client-side JavaScript. Reach them through a server-side endpoint and call it with `Http`, which does run in the browser — it is backed by `XMLHttpRequest`, not a stub. That refusal is a deliberate platform boundary; spec §9.5.5 states it for `Inference`. @@ -236,7 +236,7 @@ None of this is Vera-specific, but it validates the design choices. The thesis i This is a real concern. LLMs are trained on trillions of tokens of Python, TypeScript, and JavaScript. A MojoBench study (NAACL 2025) found that even fine-tuned models achieved only 30–35% improvement over base models on Mojo code generation, illustrating the cold-start problem for new languages. -Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (243 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification. +Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (244 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification. ## How does Vera compare to Dafny / Lean / Koka / F*? @@ -279,7 +279,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 11,670 tests, including a 243-program conformance suite +- 11,786 tests, including a 244-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 8f956cd3..2242a3a1 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -20,7 +20,6 @@ Defects in shipped compiler, runtime, or tooling behaviour — this table matche | The checker resolves a bare call to a SIBLING function's `where` helper. Spec §5 makes a helper local to its parent, and the checker's own `_lookup_function_scoped` implements that — it walks the enclosing frame stack and reads each frame's direct helpers — but it then falls back to `env.lookup_function`, and `vera/registration.py` has recursed every helper into that flat `TypeEnv`. So a top-level `other()` calling `helperx(7)`, where `helperx` is a helper of an unrelated `holder`, is **check-green and verify-green** and then refused by codegen (`Function 'helperx' is not defined in this module and was not found in any imported module`) — the helper is emitted as `holder$where$helperx`, so the bare call has no target. Loud, never a wrong answer. The op-name variant is the one that matters for the #1284 ownership predicate: with the helper named `get` and the sibling reading a `State` cell by bare `get(())`, the checker binds the HELPER and reports `[E202] Argument 0 of 'get' has type Unit, expected Int` where spec §7.4 resolves the operation — so the checker rejects a program codegen compiles correctly, the two tables disagreeing in the CHECKER's direction for the first time. Codegen's `_scoped_fns` (#1299) implements the spec rule; the fix here is a checker change, whose new rejections need their own blast-radius pass. | [#1307](https://github.com/aallan/vera/issues/1307) | | `md_parse` diverges between the native and browser runtimes on **211 of 1,471** adversarial inputs (14.3%) and **329 of 4,858** blank-line-separated sections of the project's own documentation (6.8%), measured at [PR #1303](https://github.com/aallan/vera/pull/1303) by comparing the two ADTs directly rather than their renders. The second denominator is the repository's own Markdown, so it moves whenever a document gains or loses a section; 11 of those 4,858 also differ in the rendered output. Nine classes, each with a one-line repro. The largest by a wide margin — 173 of the 211 — is *plain-text run grouping*: the browser emits one `MdText` per scan segment where the reference coalesces adjacent runs, so `**unclosed` is `[MdEmph([]), MdText("unclosed")]` natively and `[MdText("*"), MdText("*unclosed")]` in the browser. That class is invisible to `md_render` (the runs concatenate to the same text) but not to a Vera program that matches on the ADT, which is what makes it a §12.9.3 violation rather than a cosmetic one. The rest are render-visible: emphasis/strong scanning (`***both***` renders `**both****` natively, `***both***` in the browser); list-continuation indent width, where the reference strips exactly two (or three) characters and the browser strips all leading whitespace (`- a\n b` → `- a b` vs `- a b`); a `+` bullet, unrecognised in the browser; an `n)` ordered marker, likewise; a loose list, one list natively and two in the browser (`- a\n\n- b`); nesting past two levels, flattened in the browser (`- a\n - b\n - c`); a thematic break with internal spaces (`* * *`); and a table without a separator row (`\| a \| b \|\n- li`). Neither implementation is the specification — §9.7.3 pins the ADT, not the grammar that produces it — so closing this means choosing a parse for each class and stating it, then mirroring. Successor to [#1294](https://github.com/aallan/vera/issues/1294), which closed the `md_render` half; the parity suite pins the shapes they do agree on, so a regression on one of those goes red. | [#1301](https://github.com/aallan/vera/issues/1301) | | A postcondition may name a `State` the function's effect row never declares, and `vera check` accepts it: `ensures(new(State) == false)` under `effects(>)` reports OK, then `vera compile` fails with **E699** — the internal-compiler-error diagnostic whose own text says the type checker should have rejected the input, which is exactly the situation. Both forms land there for the same reason (no cell of that family exists, so `old()` finds no snapshot local and, since [#1285](https://github.com/aallan/vera/issues/1285), `new()` finds no getter). Loud and never a wrong answer, so this is diagnostic quality rather than soundness — but it is a check-green program that cannot compile, reported against the compiler instead of against the program, with a bug-report request the user should not act on. Before #1285 the `new()` side was worse than loud: the name-keyed lookup found the row's other getter and silently read the wrong cell. Fix direction: validate an `OldExpr`/`NewExpr`'s effect reference against the declared row where the checker already validates the rest of the clause, one rule for both forms; `test_a_family_the_row_does_not_declare_is_loud_on_both_sides` pins today's E699 and is the test to flip. | [#1298](https://github.com/aallan/vera/issues/1298) | -| Spec §1.4 says its reserved keywords MUST NOT be used as function names, and for **seventeen of them nothing enforces it**: `then`, `else`, `data`, `type`, `module`, `import`, `public`, `private`, `requires`, `ensures`, `invariant`, `decreases`, `effect`, `with`, `in`, `where` and `pure` all declare and pass `vera check` as `public fn (@Int -> @Int)`. (`handle` is an eighteenth acceptance and is the sanctioned host-entry-point carve-out, §5.2; the other eleven names — `resume`, `exists`, `forall`, `match`, `assert`, `assume`, `fn`, `let`, `if`, `true`, `false` — are refused **E153**.) They are not even traps: `private fn with(@Int -> @Int)` and its `where` / `type` / `pure` / `requires` / `import` siblings are **callable** from the same file, so the declaration resolves and runs like any other function. The comment above `_KEYWORD_FN_NAMES` in `vera/checker/registration.py` gives the opposite as the reason those names are absent from the set — that the contextual lexer "does not admit them as a function name, so no declaration reaches this checker at all" — so the omission rests on a premise the tree refutes, and neither the parser nor the checker holds the MUST. Two resolutions, and the choice is a ruling rather than a mechanical fix: extend `_KEYWORD_FN_NAMES` so the seventeen join `resume` under E153, or narrow §1.4's MUST to the names actually reserved. The `resume` precedent leans to enforcing — `resume` is likewise not a keyword token anywhere and likewise parses, and was reserved *at the checker* precisely because the parser had no keyword to refuse it with — and E153's own rationale, that a keyword names one construct and nothing else (DESIGN principle 3), does not depend on the name being a trap. Until the ruling lands, §1.4's `handle` carve-out sentence deliberately makes no enforcement claim, so the spec does not assert a MUST it cannot back. | [#1296](https://github.com/aallan/vera/issues/1296) | | Spec Chapter 10 is a second, hand-maintained copy of `vera/grammar.lark`, and the rule-name alignment gate `scripts/check_grammar_alignment.py` compares only rule-name headers — three classes of drift pass it unseen. **Terminals, both directions**: the header pattern requires a lowercase lead, so a fabricated terminal added to §10.2 leaves the gate green, and no declared-versus-referenced audit exists in either direction. **Rule references**: restoring a removed ambiguity to a production's right-hand side is invisible, because only headers are compared. **Production bodies**: the class most grammar edits actually fall into. Two instances are live in the chapter on `main` today, both body-level and both this issue's to close: typed holes (`"?" -> hole_expr`, in `grammar.lark` since 2026-03-30) appear nowhere in `primary_expr`, and §10.2's `BLOCK_COMMENT: /\{-[\s\S]*?-\}/` is non-nesting, contradicting both §1.3 ("They nest") and the implementation, which parses `{- a {- b -} c -}` clean. Never a wrong answer from a program — the defect is that the published grammar misdescribes the one the parser has. Fix direction: extend the gate side-aware (terminal audit both ways, reference-set comparison), or fold Chapter 10 toward the DESIGN Grammar row's actual promise of a single-sourced shared grammar rather than a hand-maintained copy held honest by ever-wider cross-checks; the two live instances are fixable independently of which direction wins. | [#1290](https://github.com/aallan/vera/issues/1290) | | `.github/workflows/release.yml`'s `Tag and create GitHub Release` step fails with `HTTP 422: Validation Failed — body is too long (maximum is 125000 characters)` when the CHANGELOG section `scripts/release.py notes` extracts into `RELEASE_NOTES.md` exceeds GitHub's release-body limit. It fired on v0.1.10, whose notes extract to roughly 148,700 bytes — the #1213 burndown's 44-issue section, some 23,000 characters past the limit — and it fired at the worst point in the pipeline: **after** PyPI had accepted the immutable archives and **after** the tag was created, leaving the release half-cut with no repeatable path back. The v0.1.10 GitHub Release was completed by hand, mirroring the step exactly: the run's artifacts downloaded and hash-verified three ways, then `gh release create --verify-tag --latest` with the wheel, sdist and SHA256SUMS, and a generated body — the section's bold bullet lead-ins as a headline index plus a link to the canonical section at the tag. Rare, since it needs a release this large, but rarity is not the mitigating factor here; the landing point is. Fix direction: make the step total — before `gh release create`, regenerate oversized notes into that index form so the release always carries a body that fits, with the full notes staying in `CHANGELOG.md`, which is already the release notes of record. | [#1288](https://github.com/aallan/vera/issues/1288) | | `ch05_closure_nat_return` (a run-level conformance program in the pre-commit + CI gate) trapped **once** in a full `check_conformance.py` run (`unreachable` in `main` — the sentinel `assert` or a GC shadow-stack guard) and has not reproduced in ~960 attempts across isolated, parallel, eager-GC, and hash-seed-swept executions; the emitted WAT is deterministic and correct. Suspected rare runtime/GC/wasmtime interaction, tracked so a future intermittent CI red resolves here instead of starting fresh. | [#996](https://github.com/aallan/vera/issues/996) | diff --git a/README.md b/README.md index 0387a8e3..b5f7593e 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ cp /path/to/vera/SKILL.md ~/.claude/skills/vera-language/SKILL.md ## Project status -Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 11,670 tests, 95% Python code coverage, 243 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. +Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 11,786 tests, 95% Python code coverage, 244 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. The reference compiler — parser, AST, type checker, contract verifier (Z3), WASM code generator, module system, browser runtime, and runtime contract insertion — is working. The language specification is in draft across [14 chapters](spec/). diff --git a/ROADMAP.md b/ROADMAP.md index 990ed872..1f54b11c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,7 +12,7 @@ Ordering derives from the design principles ([DESIGN.md](DESIGN.md)): verificati ## Where we are -11,670 tests, 243 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. +11,786 tests, 244 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. ## Stage 19 — The verification completeness sprint diff --git a/SKILL.md b/SKILL.md index 765fb998..cb82d81e 100644 --- a/SKILL.md +++ b/SKILL.md @@ -2461,7 +2461,7 @@ public fn main(@Unit -> @Unit) ## Conformance Suite -The `tests/conformance/` directory contains 243 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. +The `tests/conformance/` directory contains 244 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. Each program is organized by spec chapter (`ch01_int_literals.vera`, `ch04_match_basic.vera`, `ch07_state_handler.vera`, etc.) and the `manifest.json` file maps features to programs. When you need to see how a specific construct works, check the conformance program before reading the spec. diff --git a/TESTING.md b/TESTING.md index 159445de..af8bdbda 100644 --- a/TESTING.md +++ b/TESTING.md @@ -6,9 +6,9 @@ This is the single source of truth for Vera's testing infrastructure, coverage d | Metric | Value | |--------|-------| -| **Tests** | 11,670 across 174 files (~155,000 lines of test code; 11,473 passed + 26 stress, 171 skipped) | +| **Tests** | 11,786 across 174 files (~155,000 lines of test code; 11,587 passed + 26 stress, 173 skipped) | | **Compiler code coverage** | 95% Python, 87% JavaScript (CI minimum: 80%) | -| **Conformance programs** | 243 programs across 9 spec chapters, validating every language feature | +| **Conformance programs** | 244 programs across 9 spec chapters, validating every language feature | | **Example programs** | 42, all validated through `vera check` + `vera verify` | | **Spec code blocks** | 189 parseable blocks from 14 spec chapters: 92 parse, 86 type-check, 85 verify (the rest carry inline `vera:skip` annotations, #538) | | **README code blocks** | 4 Vera blocks (4 validated, 0 annotated) | @@ -43,7 +43,7 @@ pytest tests/test_runtime_traps.py::TestHostErrorDebugKnob1302 -v mypy vera/ # strict mode # Validation scripts -python scripts/check_conformance.py # conformance suite (243 programs, see manifest.json) +python scripts/check_conformance.py # conformance suite (244 programs, see manifest.json) python scripts/check_examples.py # 42 example programs python scripts/check_spec_examples.py # spec code blocks python scripts/check_readme_examples.py # README code blocks @@ -101,7 +101,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_db_marshalling.py` | 35 | 234 | #229 — the `` marshalling helpers: `Array>` params (inbound reader), `Array>>` query grids (`_alloc_result_ok_rows`) and `Result` row-counts, round-tripped through an `InstanceCaller` over a real compiled module — each case run normally AND under `VERA_EAGER_GC=1` (every `$alloc` fires `$gc_collect`), the large-grid case forcing free-block reuse; mutation-validated (dropping a shadow-stack root corrupts the read-back / SIGBUSes the swept-pointer read) | | `test_db_runtime.py` | 21 | 301 | #229 — the `` host binding (`vera/runtime/db.py`) on stdlib `sqlite3`: create/insert/select round-trips against `:memory:`, NULL cells → `None`, the affected-row count (incl. the `-1` DDL sentinel), a BLOB cell UTF-8-decoded with replacement, the `Err`-not-crash error path, an unopenable `VERA_DB_URL` deferred to an `Err` (not a host crash), and injection-safety (a malicious param binds as a literal, table intact); plus `_open_connection`'s `VERA_DB_URL` surface (memory + file URLs, in-memory default) and `register_db`'s bind/no-op paths | | `test_sql_provenance_309.py` | 79 | 780 | #309 — the SQL literal-provenance gate (SQL injection as a compile-time error): non-literal SQL rejected `E207` (bare param slot, function result, `\(expr)` interpolation, `string_concat` with a runtime operand, let-bound runtime value, `if`-expression), literal / concat-of-literals / let-chain-with-shadowing / empty-string accepted, placeholder/param arity `E208` with quote- and comment-aware counting (named/numbered placeholders are rejected outright, `E209`), the `count_placeholders`↔sqlite3 differential (exact count accepted, one too many rejected), and gate scoping — a user `effect DB` shadow is rejected at its declaration (`E152`, #1149) *and* its runtime SQL still draws `E207` alongside it (defence in depth), an unrelated effect's `query` is not gated, and no `E207` cascade onto a mistyped SQL arg | -| `test_checker_modules.py` | 132 | 2,298 | Module-call diagnostics, cross-module typing, visibility enforcement, builtin redefinition (function E151 and effect E152 surfaced from a module into its importer), reserved function names (E153 — the contract state forms `old` / `new` and the keyword class `assert`/`assume`/`forall`/`exists`/`match`/`if`/`let`/`fn`/`true`/`false`, each top-level, `where`-helper, and module-surfaced, plus the `handle` host-invoked carve-out and the probe record behind both halves; and `resume`, reserved on separate grounds — not a keyword, so the declaration parses and outside a handler a bare call reaches it, but it collides with the resumption binding every clause body carries, and the pins cover the rejection, the where-helper depth, that the rationale carries none of the other two branches' false claims, that handler-clause `resume(...)` still checks AND that a wrongly-typed one is still E202 — the pair, since a binding that accepted anything would satisfy the first alone — and that the rejected declaration draws no second error out of the correct clause bodies it used to shadow, at both top level and where-helper depth), parsed module calls (#420 split) | +| `test_checker_modules.py` | 242 | 2,606 | Module-call diagnostics, cross-module typing, visibility enforcement, builtin redefinition (function E151 and effect E152 surfaced from a module into its importer), reserved function names (E153 — the contract state forms `old` / `new` and the keyword class `assert`/`assume`/`forall`/`exists`/`match`/`if`/`let`/`fn`/`true`/`false`, each top-level, `where`-helper, and module-surfaced, plus the `handle` host-invoked carve-out and the probe record behind both halves; the twenty-one *contextual* keywords `then`/`else`/`data`/`type`/`module`/`import`/`public`/`private`/`requires`/`ensures`/`invariant`/`decreases`/`effect`/`with`/`in`/`where`/`pure`/`ability`/`effects`/`op`/`result`, derived from `grammar.lark` rather than hand-listed and reachable rather than traps — each declared, was called and answered its value before the fix — over five parametrized batteries (declaration, visibility, `where`-helper, a rationale free of the keyword branch's false unreachability claim, and a usable per-name fix suggestion) with `handle` and fifteen keyword-containing names as controls; and `resume`, reserved on separate grounds — not a keyword, so the declaration parses and outside a handler a bare call reaches it, but it collides with the resumption binding every clause body carries, and the pins cover the rejection, the where-helper depth, that the rationale carries none of the other two branches' false claims, that handler-clause `resume(...)` still checks AND that a wrongly-typed one is still E202 — the pair, since a binding that accepted anything would satisfy the first alone — and that the rejected declaration draws no second error out of the correct clause bodies it used to shadow, at both top level and where-helper depth), parsed module calls (#420 split) | | `test_checker_errors.py` | 73 | 1,196 | Error codes, resolution-coverage diagnostics, contracts, error accumulation (#420 split); cyclic type aliases incl. #1059 self-reference through a type argument (`Future`, mutual `Future`/`Future`, `Array`) rejected E132 | | `test_checker_builtins_collections.py` | 97 | 848 | Map / Set / Decimal / Json / Html / Http / Inference built-in type-checking (#420 split) | | `test_checker_builtins_strings.py` | 122 | 945 | String / numeric / type-conversion / float-predicate / string-search / markdown / regex built-in type-checking, removed-legacy-name regression (#420 split) | @@ -192,7 +192,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_string_length_soundness.py` | 15 | 278 | #802 — string_length code-point vs UTF-8 byte soundness: a non-literal `string_length` defers to Tier 3 (the issue's `"é"` probe no longer proves `== 1` at Tier 1), a string-literal length is modeled at its exact UTF-8 byte count (`== 2` for `"é"`), and the boolean predicates `string_contains` / `string_starts_with` / `string_ends_with` stay Tier 1 (sound under UTF-8 self-synchronization), while a predicate over an astral (> U+2FFFF) or lone-surrogate literal defers to Tier 3 (z3.StringVal cannot model those code points) | | `test_errors.py` | 62 | 657 | Error code registry, diagnostic formatting, serialisation, SourceLocation, and error display sync — the canonical `E001` diagnostic must match each of its mirrors: `README.md`, `docs/index.html`, `spec/00-introduction.md`, `AGENTS.md`'s example `--json` block, and the hardcoded example in `scripts/build_site.py` that generates `docs/index.md` (#829; `AGENTS.md`'s ellipsis-truncated description/rationale are prefix-compared, its `error_code`/`spec_ref`/`fix` exactly) | | `test_eq_contract_874.py` | 13 | 430 | `eq`/`compare` ability ops in contract position: codegen canonicalization + verifier Tier-1 discharge/counterexample, where-fn contracts, compare Ordering-sort materialization, shadowing guard (#874) | -| `test_formatter.py` | 538 | 3,638 | Comment extraction, interior comment positioning, expression/declaration formatting, match arm block bodies, §1.8 rule 2 in value position (a `let`-bound `match`/`if` expands exactly as one in statement position, and a comment above an arm inside a statement's value stays on that arm), blank-line preservation (§1.8 rule 13 — gaps between statements, before a block result and around a comment, collapsed to one and never invented), idempotency, parenthesization, spec rules, ability declarations | +| `test_formatter.py` | 539 | 3,638 | Comment extraction, interior comment positioning, expression/declaration formatting, match arm block bodies, §1.8 rule 2 in value position (a `let`-bound `match`/`if` expands exactly as one in statement position, and a comment above an arm inside a statement's value stays on that arm), blank-line preservation (§1.8 rule 13 — gaps between statements, before a block result and around a comment, collapsed to one and never invented), idempotency, parenthesization, spec rules, ability declarations | | `test_cli.py` | 273 | 4,612 | CLI commands (check, verify, compile, run, serve, test, fmt, version, quiet), subprocess integration, JSON error paths (including the `verify --json` `obligations` array and its summary-reproducibility pin, #967, and the #1242 partition pin — the array is emitted unfiltered, a refuted obligation is counted by no summary field, and it still joins its E500 on the location key), runtime traps, arg validation, multi-file resolution, IO exit codes, --explain-slots (including the #1208 naming pins — an alias in type-argument position is tabled resolved, and a `forall` variable shadowing a module alias keeps the two parameter stacks apart — and the #1217 `where`-helper tables: the helper prints indented under its parent, appears in the JSON qualified as `parent.helper`, and inherits the enclosing `forall` variables so the shadowing holds inside it too), `builtins`/`effects`/`errors` introspection dispatch, and a USAGE-completeness guard (every dispatched `cmd_` handler has a help row) | | `test_introspect.py` | 39 | 221 | `vera builtins/effects/errors --json` registry introspection (#539): the `{schema, items}` envelope, count-equals-registry differential per registry, error-phase derivation, effect/ability `kind` tagging, the parameterised `Exn` effect, and best-effort `since` attribution with full-coverage guards | | `test_resolver.py` | 20 | 602 | Module resolution, path lookup, parse caching, circular import detection, the E011/E012/E013 diagnostic contract, internal-error isolation (a compiler bug is not masked as E013), and the transitive-closure return of `resolve_imports` (#890 — a diamond yields each reachable module once, direct imports tagged `direct`, the transitive one not) | @@ -210,7 +210,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_markdown.py` | 94 | 610 | Markdown parser: block/inline parsing, rendering, round-trips, edge cases | | `test_lsp.py` | 146 | 2470 | LSP transport + coordinate layer (#222 Phase C) and language features (#222 Phase D): parametrized code-point↔UTF-16 goldens incl. astral-plane fixtures and surrogate-pair snapping, Span (1-based, exclusive-end) and SourceLocation (0-based col) → LSP Range conversions, point→token-range widening, DocumentStore open/change/close + index invalidation, an in-process handler-drive test, and one stdio end-to-end round-trip against the real `vera lsp` subprocess (initialize → didOpen → shutdown → exit) pinning serverInfo + textDocumentSync capabilities; plus the Phase D feature suite — parse-error single-diagnostic path, type-error verification short-circuit, tier=3 in E520 diagnostic data, per-function tier Hint synthesis (and its suppression for functions with violated obligations), smallest-enclosing-span hover, De Bruijn slot goto (most-recent-parameter jump, out-of-range None, off-slot None, and the #1208 keying pins: a parameterised reference resolves, an alias-spelled parameter is reachable from a canonically-spelled reference, and a `forall` variable shadowing a module alias lands on the right parameter), and typed-hole completion (inside/after hole, away-from-hole None); plus the Phase E speculativeEdit suite — identical-text all-unchanged, breaking edit surfaces newly_undischarged (violated nat_sub) with canonical state untouched, strengthening edit surfaces newly_discharged, parse/type errors report ok:false, deleted functions report removed, proof_delta purity; plus the Phase F1 proposeEdit suite — the apply gate (clean and strengthening edits apply, breaking and non-compiling edits refuse), force overriding both gates with the delta still reported, wiring against a structural fake server (apply round-trip with exact full-document replacement range, refuse touches no canonical state, unopened-URI clamp sentinel), and full-document-range goldens (trailing-newline virtual line, UTF-16 end column); plus the Phase F2 strengthenContract suite — splice goldens (first-clause-only replacement with byte-identical remainder, ensures variant, unknown-fn None), the call-site audit pin (tightened precondition refused with newly_undischarged call_pre items, canonical state untouched), provable-ensures strengthening applies, and the three splice-target refusal paths (no analysis, unparseable document, unknown function); plus the Phase F3 addEffect suite — transitive-caller closure goldens (diamond in declaration order, leaf, unknown-fn None, recursion appears once), handler bounding (#725: a caller discharging the effect around its only call site drops out of the closure and is not rewritten, while a second unhandled path, a call in a handler clause, a handler for another effect, a handler naming a different instance of the same effect (`handle[State]` against a `State` propagation, which the checker does not discharge — end-to-end that caller must still be rewritten and the candidate must still apply, with the matching `State` propagation against the same fixture as the positive control that this handler key does prune something), and a bare `where`-helper call all keep it in, as does a refinement type argument at either depth (`Exn<{ @Int \| p }>` and `Exn>` render as their bare base type but discharge nothing of `Exn` — also pinned end-to-end), while an unparameterised `handle[IO]` does bound an `IO` propagation, a whitespace-spelled `State< Int >` request still bounds, and nesting bounds in either order (a matching handler inside a foreign one, a foreign one inside a matching one) — plus a key-level pin that `handle[Mod.IO]` keeps its module, and the effect-less query pinned handler-unaware; plus the two boundary pins the review added — a call in the handler's STATE INITIALISER keeps its edge, since the initialiser is evaluated in the enclosing scope before the handler is installed (pinned beside the E125 the checker raises there against a `pure` caller, with the identical call in the handler body clean as the contrast), and an alias-spelled handler does not bound a `State` propagation though the checker discharges it (`handle[State]` with `type MyAlias = Int` — the spelling comparison's under-prune, #1292, with the alias-spelled request as the control that does prune)), effect-row rewrite goldens (pure to singleton set, source-preserving append, already-present None, base-name identity blocking State next to State), diamond propagation applying one multi-site candidate with the bystander untouched, mixed append/replace rows with already-satisfied callers skipped, the fully-satisfied no-op shape, and the two refusal paths; plus the #728 instruction-contract suite — the LSP message carries description, rationale, and the Fix: paragraph (also pinning single E501 emission at the LSP surface), and a bare diagnostic maps to the description alone | | `test_browser.py` | 413 | 5,094 | Browser parity: Python/wasmtime vs Node.js/JS-runtime output equivalence across IO, State, contracts, Markdown, Regex, and the examples the browser target can execute (two explicit lists in the file, not the whole `examples/` directory — interactive stdin, file IO, `DB` and the non-standalone `modules` example are excluded with their reasons recorded); plus the #349 `runtime.mjs` coverage battery — per-value-type and per-key-type Map variants, per-element-type Set variants, cold `Decimal` branches (exact-zero sign, negative-shift division, `decRoundPlaces` special cases, non-finite storage), `readJson`/`json_stringify` across every Json ADT tag, the Regex/Json `Result.Err` arms, and nested-Markdown walks. Two operations carry a canonical form the specification states rather than merely agreeing across the hosts, so their batteries assert more than equality: `json_stringify` (spec §9.7.1) pins the expected string on every Json ADT tag and on the number-rendering boundaries, checks three-pass idempotence, checks that a non-finite `JNumber` fails on both hosts *and* prints nothing, pins the object key orders an ordinary JS object cannot carry (array-index keys, which ES enumeration hoists to the front in ascending numeric order, and a `__proto__` key, whose assignment writes a prototype instead of a field) on both a parsed and a program-built object, and checks the reference host's own number rendering differentially against a real `JSON.stringify` over doubles drawn from raw bit patterns; `md_render` (§9.7.3) pins the expected render, re-renders it to prove the fixed point, runs the round-trip property over a corpus carrying the container and multi-line shapes a flat corpus misses, and renders `MdBlock` values the test builds directly, since several renderer rules — a container's child separator, an empty container, a code span wider than one backtick — are unreachable through `md_parse`. plus the #1306/#1308 accept-domain battery (`TestBrowserJsonAcceptDomainParity1306_1308`), which runs one `.wasm` under both runtimes and compares the WHOLE stdout — arm taken and `Err` message together — over the JavaScript constants, over numbers that overflow to an infinity in both their exponent and integer spellings, and over lone surrogates at every position a string can occupy, with the expected sentences imported from `vera/wasm/json_serde.py` so `runtime.mjs`'s hand-copied duplicates are held against the originals, beside acceptance controls (matched pairs, finite boundary values, underflow to `0`, `"NaN"` as a string value), a ten-case host-native-message battery pinning that neither the substitute-and-re-parse probe nor the value-start constraint hijacks an unrelated syntax error (`-NaN` is the case needing both rules — the substitution alone turns it into `-0` and manufactures a refusal the reference host never makes), a precedence case fixing that a non-finite constant outranks a lone surrogate on both hosts though each reaches that answer by a different route, and a document-order case fixing that overflow and lone surrogate are resolved by one walk rather than by two per-host precedence rules that would agree on every single-violation document; `md_parse` itself is not yet at parity and its remaining divergence classes are a tracked bug ([#1301](https://github.com/aallan/vera/issues/1301)); the suite pins the shapes the two implementations agree on | -| `test_conformance.py` | 1215 | 144 | Parametrized conformance suite: parse, check, verify, run, format idempotency across 243 programs; a negative entry fails at the stage `expected_error_stage` names (`check`, the default, or `compile` — which also asserts the program type-checks cleanly first) | +| `test_conformance.py` | 1220 | 144 | Parametrized conformance suite: parse, check, verify, run, format idempotency across 244 programs; a negative entry fails at the stage `expected_error_stage` names (`check`, the default, or `compile` — which also asserts the program type-checks cleanly first) | | `test_prelude.py` | 29 | 585 | Prelude injection: Option/Result/array operation detection, combinator shadowing, type aliases, the reserved namespace every injected alias declaration lives in — checked against the checker's own E154 regex rather than a second spelling of the rule, since an alias the prelude injects outside it is one codegen resolves and the checker leaves opaque (#1184/#1221) — end-to-end compilation | | `test_checker_apply_fn.py` | 18 | 455 | #854 — `apply_fn` as a checker special form: zero-warning pins (API + CLI `--json` + closures.vera), E201 arity / E202 type / non-function-first-arg errors, E122/E125 effect-row enforcement for applied fn values, E151 redefinition rejection, variadic two-param application, prelude combinator regression pins | | `test_prelude_diagnostics.py` | 8 | 271 | #851 — prelude combinator skip-warnings: unreferenced-prelude E602/E604 suppression (zero-warning minimal compile, API + CLI `--json`), `` origin attribution for referenced-but-skipped combinators (text + `to_dict`), transitive reference scan, and user-fn warning locations pinned unchanged | @@ -236,7 +236,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime ## Conformance Suite -The conformance suite is a collection of 243 small, focused programs in `tests/conformance/` that systematically validate every language feature against the spec. Most programs are self-contained; the module-focused Chapter 8 cases use `import` statements where needed, and `ch07_cross_module_contracts.vera` still depends on `ch07_cross_module_contracts_lib.vera`. Each program tests one feature or a small group of related features. +The conformance suite is a collection of 244 small, focused programs in `tests/conformance/` that systematically validate every language feature against the spec. Most programs are self-contained; the module-focused Chapter 8 cases use `import` statements where needed, and `ch07_cross_module_contracts.vera` still depends on `ch07_cross_module_contracts_lib.vera`. Each program tests one feature or a small group of related features. Simon Willison [argues](https://simonwillison.net/tags/conformance-suites/) that conformance suites are a "huge unlock" for language projects — they transform development from trust-based to verification-based. The conformance suite serves as the definitive specification artifact that any implementation (or agent) can validate against. @@ -261,15 +261,15 @@ Each conformance program declares the deepest pipeline stage it must pass: | Level | What it validates | Count | |-------|-------------------|------:| | `parse` | Source text is syntactically valid | 0 | -| `check` | Parses and type-checks cleanly | 49 | +| `check` | Parses and type-checks cleanly | 50 | | `verify` | Type-checks and all contracts verified by Z3 | 20 | | `run` | Compiles to WASM and executes correctly | 174 | -Almost all programs are at the `run` level — they compile and execute, producing correct results. Forty-nine programs (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch03_typed_holes`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_cross_module_contracts_lib`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch07_bare_effect_op_rejected`, `ch08_ambiguous_import_adt_lib_bool`, `ch08_ambiguous_import_adt_lib_int`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_lib_bool`, `ch08_ambiguous_import_lib_int`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_cross_module_generic_lib`, `ch08_module_generic_diamond_base`, `ch08_module_prelude_adt_contention_rejected`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_transitive_module_import_base`, `ch08_visibility_private`, `ch08_xmod_widen_lib`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_eq_non_derivable_rejected`, `ch09_http`, `ch09_inference`, `ch09_ord_adt_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) are at the `check` level. Thirty-six of them — `ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch07_bare_effect_op_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, and `ch02_alias_cycle_rejected` — are **negative tests** that assert a specific diagnostic (E206, E135, E183, E201, E127, E153, E153, E153, E130, E130, E174, E182, E217, E156, E156, E155, E155, E011, E154, E154, E154, E154, E154, E154, E150, E152, E151, E242, E243, E207, E208, E208, E209, E128, E336, and E132 respectively) via the manifest's `expected_error` field. One more — `ch08_module_prelude_adt_contention_rejected` — is a negative at the `compile` stage rather than at `check`: it carries `expected_error_stage: "compile"` beside `expected_error: E621`, so the harness asserts it type-checks CLEANLY and is then refused by `vera compile` with that code, which is the property a codegen-phase diagnostic exists for. `ch09_http` and `ch09_inference` are environment-gated (network / API key). Twenty programs (`ch03_slot_let_chains`, `ch03_slot_noncommutative`, `ch04_nested_option_ctor`, `ch04_primitive_obligations`, `ch05_apply_fn_typing`, `ch06_adt_sort_disambiguation`, `ch07_cross_module_contracts`, `ch07_invisible_import_op_name_lib`, `ch07_io_read_char`, `ch07_io_sleep`, `ch07_random_effect`, `ch08_state_alias_module_table_lib`, `ch08_module_generic_diamond_mid1`, `ch08_module_generic_diamond_mid2`, `ch08_state_alias_per_module_lib`, `ch08_transitive_module_import_mid`, `ch09_http_server`, `ch09_invisible_import_ability_op_lib`, `ch09_math_builtins`, `ch09_nested_helper_family_op_name_lib`) are at the `verify` level, using Z3-provable contracts — a library module is pinned at the deepest level it reaches, so the two per-module alias-table libraries are verified rather than only checked. +Almost all programs are at the `run` level — they compile and execute, producing correct results. Fifty programs (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch03_typed_holes`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_contextual_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_cross_module_contracts_lib`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch07_bare_effect_op_rejected`, `ch08_ambiguous_import_adt_lib_bool`, `ch08_ambiguous_import_adt_lib_int`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_lib_bool`, `ch08_ambiguous_import_lib_int`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_cross_module_generic_lib`, `ch08_module_generic_diamond_base`, `ch08_module_prelude_adt_contention_rejected`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_transitive_module_import_base`, `ch08_visibility_private`, `ch08_xmod_widen_lib`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_eq_non_derivable_rejected`, `ch09_http`, `ch09_inference`, `ch09_ord_adt_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`) are at the `check` level. Thirty-seven of them — `ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_contextual_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch07_bare_effect_op_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, and `ch02_alias_cycle_rejected` — are **negative tests** that assert a specific diagnostic (E206, E135, E183, E201, E127, E153, E153, E153, E153, E130, E130, E174, E182, E217, E156, E156, E155, E155, E011, E154, E154, E154, E154, E154, E154, E150, E152, E151, E242, E243, E207, E208, E208, E209, E128, E336, and E132 respectively) via the manifest's `expected_error` field. One more — `ch08_module_prelude_adt_contention_rejected` — is a negative at the `compile` stage rather than at `check`: it carries `expected_error_stage: "compile"` beside `expected_error: E621`, so the harness asserts it type-checks CLEANLY and is then refused by `vera compile` with that code, which is the property a codegen-phase diagnostic exists for. `ch09_http` and `ch09_inference` are environment-gated (network / API key). Twenty programs (`ch03_slot_let_chains`, `ch03_slot_noncommutative`, `ch04_nested_option_ctor`, `ch04_primitive_obligations`, `ch05_apply_fn_typing`, `ch06_adt_sort_disambiguation`, `ch07_cross_module_contracts`, `ch07_invisible_import_op_name_lib`, `ch07_io_read_char`, `ch07_io_sleep`, `ch07_random_effect`, `ch08_state_alias_module_table_lib`, `ch08_module_generic_diamond_mid1`, `ch08_module_generic_diamond_mid2`, `ch08_state_alias_per_module_lib`, `ch08_transitive_module_import_mid`, `ch09_http_server`, `ch09_invisible_import_ability_op_lib`, `ch09_math_builtins`, `ch09_nested_helper_family_op_name_lib`) are at the `verify` level, using Z3-provable contracts — a library module is pinned at the deepest level it reaches, so the two per-module alias-table libraries are verified rather than only checked. ### Skipped tests -`pytest tests/ -v` skips 118 conformance-stage tests, and every one of them is the level rule: a program declared at `check` skips its `verify` and `run` stages, one declared at `verify` skips its `run` — 49 × 2 + 20, which is what the suite reports. The two tables below split those 118 by why the program sits at its level, not by how it skipped: 114 are pinned there by the feature under test, and 4 by an environment CI does not have. Each skip is listed once; the tables do not overlap. (The suite's remaining skips are platform- or tool-gated and documented beside the tests that declare them.) +`pytest tests/ -v` skips 120 conformance-stage tests, and every one of them is the level rule: a program declared at `check` skips its `verify` and `run` stages, one declared at `verify` skips its `run` — 50 × 2 + 20, which is what the suite reports. The two tables below split those 120 by why the program sits at its level, not by how it skipped: 116 are pinned there by the feature under test, and 4 by an environment CI does not have. Each skip is listed once; the tables do not overlap. (The suite's remaining skips are platform- or tool-gated and documented beside the tests that declare them.) **Level-limited skips** — the conformance framework only runs tests up to the declared level; stages beyond that level are automatically skipped. These are expected and correct. @@ -303,6 +303,8 @@ Almost all programs are at the `run` level — they compile and execute, produci | `test_run[ch05_reserved_fn_name_rejected]` | `ch05_reserved_fn_name_rejected.vera` | `check` | `run` | `check`-level negative test: no `run` stage | | `test_verify[ch05_reserved_keyword_fn_rejected]` | `ch05_reserved_keyword_fn_rejected.vera` | `check` | `verify` | `check`-level negative test (`expected_error: E153`): verify stage not run | | `test_run[ch05_reserved_keyword_fn_rejected]` | `ch05_reserved_keyword_fn_rejected.vera` | `check` | `run` | `check`-level negative test: no `run` stage | +| `test_verify[ch05_reserved_contextual_keyword_fn_rejected]` | `ch05_reserved_contextual_keyword_fn_rejected.vera` | `check` | `verify` | `check`-level negative test (`expected_error: E153`): verify stage not run | +| `test_run[ch05_reserved_contextual_keyword_fn_rejected]` | `ch05_reserved_contextual_keyword_fn_rejected.vera` | `check` | `run` | `check`-level negative test: no `run` stage | | `test_verify[ch05_reserved_resume_fn_rejected]` | `ch05_reserved_resume_fn_rejected.vera` | `check` | `verify` | `check`-level negative test (`expected_error: E153`): verify stage not run | | `test_run[ch05_reserved_resume_fn_rejected]` | `ch05_reserved_resume_fn_rejected.vera` | `check` | `run` | `check`-level negative test: no `run` stage | | `test_verify[ch05_where_helper_outer_slot_rejected]` | `ch05_where_helper_outer_slot_rejected.vera` | `check` | `verify` | `check`-level negative test (`expected_error: E130`): verify stage not run | @@ -409,7 +411,7 @@ tests/conformance/ ├── ch01_int_literals.vera # Chapter 1: Integer literals ├── ch01_float_literals.vera # Chapter 1: Float64 literals ├── ch01_string_escapes.vera # Chapter 1: String escape sequences -├── ... # 243 programs total, organized by spec chapter +├── ... # 244 programs total, organized by spec chapter ├── ch07_state_handler.vera # Chapter 7: State effect handler ├── ch07_exn_handler.vera # Chapter 7: Exn effect handler ├── ch09_numeric_builtins.vera # Chapter 9: Numeric built-in functions @@ -443,7 +445,7 @@ The manifest is the machine-readable feature inventory — agents can query it t ### Running the conformance suite ```bash -# Via pytest (parametrized — 1,215 tests: five stages × 243 entries) +# Via pytest (parametrized — 1,220 tests: five stages × 244 entries) pytest tests/test_conformance.py -v # Via standalone script (used in CI and pre-commit) @@ -972,9 +974,9 @@ Twenty-nine scripts in `scripts/` validate cross-cutting concerns beyond unit te | Script | What it validates | |--------|-------------------| -| `check_conformance.py` | All 243 conformance entries hold at their declared level (parse/check/verify/run) — positives pass; the negatives fail at the stage their `expected_error_stage` names (`check` by default, or `compile`, which also asserts the program type-checks cleanly) with their `expected_error` E-code | +| `check_conformance.py` | All 244 conformance entries hold at their declared level (parse/check/verify/run) — positives pass; the negatives fail at the stage their `expected_error_stage` names (`check` by default, or `compile`, which also asserts the program type-checks cleanly) with their `expected_error` E-code | | `check_examples.py` | All 42 `.vera` examples pass `vera check` + `vera verify` | -| `check_corpus_canonical.py` | All 292 corpus programs (recursive over `examples/` + `tests/conformance/`) are in canonical form under `vera fmt` | +| `check_corpus_canonical.py` | All 293 corpus programs (recursive over `examples/` + `tests/conformance/`) are in canonical form under `vera fmt` | | `check_examples_readme.py` | Every `vera run` command in examples/README.md references an existing file and exported function | | `check_spec_examples.py` | 189 parseable code blocks from spec chapters: parse, type-check, and verify | | `check_readme_examples.py` | All Vera code blocks in README.md parse correctly | @@ -1081,9 +1083,9 @@ The repository configures 36 hooks across two stages: 34 run at the commit stage | `ruff check .` | Lint Python with ruff (default `F` + `E` rules) | | `mypy vera/` | Type-check compiler in strict mode | | `pytest tests/ -q` | Run full test suite | -| `check_conformance.py` | All 243 conformance entries hold at their declared level — positives pass; negatives fail at the stage their `expected_error_stage` names (`check` or `compile`) with their `expected_error` E-code | +| `check_conformance.py` | All 244 conformance entries hold at their declared level — positives pass; negatives fail at the stage their `expected_error_stage` names (`check` or `compile`) with their `expected_error` E-code | | `check_examples.py` | All 42 examples pass `vera check` + `vera verify` | -| `check_corpus_canonical.py` | All 292 `examples/` + `tests/conformance/` programs (recursive) are in canonical form (`vera fmt`) | +| `check_corpus_canonical.py` | All 293 `examples/` + `tests/conformance/` programs (recursive) are in canonical form (`vera fmt`) | | `check_examples_readme.py` | `vera run` commands in `examples/README.md` reference existing files and exported functions | | `check_readme_examples.py` | README code blocks parse correctly | | `check_examples_doc.py` | EXAMPLES.md code blocks parse correctly | diff --git a/docs/SKILL.md b/docs/SKILL.md index 3d801e1f..71aca508 100644 --- a/docs/SKILL.md +++ b/docs/SKILL.md @@ -2402,7 +2402,7 @@ public fn main(@Unit -> @Unit) ## Conformance Suite -The `tests/conformance/` directory contains 243 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. +The `tests/conformance/` directory contains 244 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. Each program is organized by spec chapter (`ch01_int_literals.vera`, `ch04_match_basic.vera`, `ch07_state_handler.vera`, etc.) and the `manifest.json` file maps features to programs. When you need to see how a specific construct works, check the conformance program before reading the spec. diff --git a/docs/index.html b/docs/index.html index 08726d78..433fb0a2 100644 --- a/docs/index.html +++ b/docs/index.html @@ -699,7 +699,7 @@

This page is also a machine-readable specificati Vera is under active development

- A complete compiler with 164 built-in functions, ten algebraic effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge), contract-driven testing via Z3, a language server with agent-facing proof deltas, and a 14-chapter specification. A 243-program conformance suite and 42 worked examples are validated against the spec on every pull request. All of it is developed openly on GitHub and released under the MIT licence. + A complete compiler with 164 built-in functions, ten algebraic effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge), contract-driven testing via Z3, a language server with agent-facing proof deltas, and a 14-chapter specification. A 244-program conformance suite and 42 worked examples are validated against the spec on every pull request. All of it is developed openly on GitHub and released under the MIT licence.

diff --git a/docs/index.md b/docs/index.md index f779ffdf..e7b2b281 100644 --- a/docs/index.md +++ b/docs/index.md @@ -281,7 +281,7 @@ For other models: point them at [`SKILL.md`](https://veralang.dev/SKILL.md) via ## Status -Vera is under [active development](https://raw.githubusercontent.com/aallan/vera/main/ROADMAP.md). A complete compiler with 164 built-in functions, ten algebraic effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge), contract-driven testing via [Z3](https://www.microsoft.com/en-us/research/project/z3-3/), and a 14-chapter specification. A 243-program conformance suite and 42 worked examples are validated against the spec on every pull request. All of it is developed openly on [GitHub](https://github.com/aallan/vera) and released under the MIT licence. +Vera is under [active development](https://raw.githubusercontent.com/aallan/vera/main/ROADMAP.md). A complete compiler with 164 built-in functions, ten algebraic effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge), contract-driven testing via [Z3](https://www.microsoft.com/en-us/research/project/z3-3/), and a 14-chapter specification. A 244-program conformance suite and 42 worked examples are validated against the spec on every pull request. All of it is developed openly on [GitHub](https://github.com/aallan/vera) and released under the MIT licence. ## Links diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 7c7d4184..bb31edc9 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -2408,7 +2408,7 @@ public fn main(@Unit -> @Unit) ## Conformance Suite -The `tests/conformance/` directory contains 243 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. +The `tests/conformance/` directory contains 244 small programs — most self-contained, with the Chapter 8 module-system programs and a few cross-module Chapter 7 and 9 programs importing companion `_lib.vera` / `_mid.vera` modules — that validate every language feature against the spec — often one program per feature, though some features (slot references, match, contracts) span several. These are the best minimal working examples of Vera syntax and semantics. Each program is organized by spec chapter (`ch01_int_literals.vera`, `ch04_match_basic.vera`, `ch07_state_handler.vera`, etc.) and the `manifest.json` file maps features to programs. When you need to see how a specific construct works, check the conformance program before reading the spec. @@ -2487,7 +2487,7 @@ Read `SKILL.md` for the full language reference. It covers syntax, slot referenc ### Conformance programs as reference -The conformance suite in `tests/conformance/` contains 243 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-seven negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) instead must *fail* with the E-code in their `expected_error` field, at the stage their `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. +The conformance suite in `tests/conformance/` contains 244 small, self-contained programs — often one per language feature — that serve as minimal working examples (most are fully self-contained; the cross-module programs of Chapters 7–9 import companion `_lib`/module fixtures). Each positive program must pass its declared verification level (see `manifest.json` for mappings: `parse`, `check`, `verify`, or `run`); the thirty-eight negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_contextual_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) instead must *fail* with the E-code in their `expected_error` field, at the stage their `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses. When you need to see how a specific construct works (e.g. effect handlers, match expressions, closures), check the corresponding conformance program before reading the spec. ### Workflow @@ -2666,9 +2666,9 @@ Each stage is a module with a single public API function (`parse_file`, `transfo pytest tests/ -v # Run all tests (see TESTING.md) pytest tests/test_conformance.py -v # Conformance suite only mypy vera/ # Type-check the compiler -python scripts/check_conformance.py # All 243 conformance programs hold (positives pass; negatives fail with their E-code) +python scripts/check_conformance.py # All 244 conformance programs hold (positives pass; negatives fail with their E-code) python scripts/check_examples.py # All 42 examples must pass -python scripts/check_corpus_canonical.py # All 292 corpus programs in canonical form +python scripts/check_corpus_canonical.py # All 293 corpus programs in canonical form ``` Test helpers follow a pattern: `_check_ok(source)` / `_check_err(source, match)` / `_verify_ok(source)` / `_verify_err(source, match)`. See existing tests for examples. @@ -2677,7 +2677,7 @@ When implementing a new language feature, write the conformance program *first* ### Invariants -- All 243 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first +- All 244 conformance programs in `tests/conformance/` must hold at their declared level — positive entries pass, and the negative fixtures (`ch02_generic_over_unit_rejected`, `ch02_map_unit_value_rejected`, `ch04_let_unit_rejected`, `ch05_apply_fn_arity`, `ch05_decreases_float_rejected`, `ch05_reserved_fn_name_rejected`, `ch05_reserved_keyword_fn_rejected`, `ch05_reserved_contextual_keyword_fn_rejected`, `ch05_reserved_resume_fn_rejected`, `ch05_where_helper_outer_slot_rejected`, `ch07_handler_state_body_scope_rejected`, `ch07_old_outside_ensures_rejected`, `ch07_state_unit_op_param_read_rejected`, `ch08_ambiguous_import_adt_rejected`, `ch08_ambiguous_import_adt_swapped_rejected`, `ch08_ambiguous_import_rejected`, `ch08_ambiguous_import_swapped_rejected`, `ch08_circular_import`, `ch08_reserved_vera_prefix_rejected`, `ch08_reserved_vera_prefix_reference_rejected`, `ch08_reserved_vera_prefix_binder_rejected`, `ch08_reserved_vera_prefix_effect_rejected`, `ch08_reserved_vera_prefix_ability_rejected`, `ch08_reserved_vera_prefix_constructor_rejected`, `ch08_visibility_private`, `ch09_builtin_effect_redefinition_rejected`, `ch09_builtin_redefinition`, `ch09_ord_adt_rejected`, `ch09_eq_non_derivable_rejected`, `ch09_sql_injection_rejected`, `ch09_sql_placeholder_mismatch_rejected`, `ch09_sql_placeholder_let_mismatch_rejected`, `ch09_sql_numbered_placeholder_rejected`, `ch07_bare_effect_op_rejected`, `ch06_quantifier_array_domain_rejected`, `ch07_handler_state_type_mismatch_rejected`, `ch02_alias_cycle_rejected`, `ch08_module_prelude_adt_contention_rejected`) must *fail* with their `expected_error` E-code, at the stage `expected_error_stage` names — `check` by default, or `compile` for a diagnostic the checker accepts and codegen refuses (`ch08_module_prelude_adt_contention_rejected` → E621), which also asserts the program type-checks cleanly first - All 42 examples in `examples/` must pass `vera check` and `vera verify` - `mypy vera/` must be clean - `pytest tests/ -v` must pass @@ -3156,7 +3156,7 @@ vera compile --target browser examples/hello_world.vera # index.html ``` -Serve it with any HTTP server and open `index.html` — no build step, no bundler, no dependencies. The JavaScript runtime provides browser-appropriate implementations of the host bindings the browser target supports — the ones a page can host, which leaves a filesystem, an accept loop, a database and a model provider outside it by construction (spec §12.9.3 lists each and why): `IO.print` writes to the page, `IO.read_line` uses `prompt()`, and State, contracts, JSON serialization and Markdown rendering work identically to the wasmtime runtime. `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states — §9.7.1 and §9.7.3 — rather than by the two hosts happening to agree, which is what the parity suite checks them against. `json_parse` reaches it from the other side, by accepted domain rather than by output form: §9.7.1 states what it takes — RFC 8259-valid text that decodes to finite numbers and strings of Unicode scalar values — so the JavaScript constants (`NaN`, `Infinity`, `-Infinity`) and a lone-surrogate escape are `Err` at the parse on both hosts, with one message, and every text inside the domain parses identically. `md_parse` is the one operation on the shared surface still to reach parity: the two hand-written parsers disagree across nine measured classes of input the §9.7.3 subset leaves open, the largest by a wide margin being how a paragraph's plain-text runs are grouped — invisible to `md_render`, since the runs concatenate to the same text — and the rest render-visible, from how emphasis markers are scanned to block markers such as a `+` bullet or a list nested more than two deep. That one is tracked as [#1301](https://github.com/aallan/vera/issues/1301). +Serve it with any HTTP server and open `index.html` — no build step, no bundler, no dependencies. The JavaScript runtime provides browser-appropriate implementations of the host bindings the browser target supports — the ones a page can host, which leaves a filesystem, an accept loop, a database and a model provider outside it by construction (spec §12.9.3 lists each and why): `IO.print` writes to the page, `IO.read_line` uses `prompt()`, and State, contracts, JSON serialization and Markdown rendering work identically to the wasmtime runtime. `json_stringify` and `md_render` reach that identity by emitting a canonical form the specification states — §9.7.1 and §9.7.3 — rather than by the two hosts happening to agree, which is what the parity suite checks them against. `json_parse` reaches it from the other side, by accepted domain rather than by output form: §9.7.1 states what it takes — RFC 8259-valid text that decodes to finite numbers and strings of Unicode scalar values — so the JavaScript constants (`NaN`, `Infinity`, `-Infinity`) and a lone-surrogate escape are `Err` at the parse on both hosts, with one message, and every text inside the domain parses identically. `md_parse` is the one operation on the shared surface still to reach parity: the two hand-written parsers disagree across nine measured classes of input the §9.7.3 subset leaves open, the largest by a wide margin being how a paragraph's plain-text runs are grouped — invisible to `md_render`, since the runs concatenate to the same text — and the rest render-visible, from how emphasis markers are scanned to block markers such as a `+` bullet or a list nested more than two deep. That one is tracked as [#1301](https://github.com/aallan/vera/issues/1301). `IO.read_char` is separately not yet supported in the browser target at all, and is a not-yet rather than one of the boundaries above — a page could host it, and until the JSPI suspend/resume primitive it needs lands the stub returns an explanatory `Err` reading `IO.read_char not yet supported in browser target`. Two effects are refused outright rather than merely differing. `Inference` and `DB` return an explanatory `Err` from every operation in the browser, because the API key or database credential they would need is readable from page source and network traffic in client-side JavaScript. Reach them through a server-side endpoint and call it with `Http`, which does run in the browser — it is backed by `XMLHttpRequest`, not a stub. That refusal is a deliberate platform boundary; spec §9.5.5 states it for `Inference`. @@ -3222,7 +3222,7 @@ None of this is Vera-specific, but it validates the design choices. The thesis i This is a real concern. LLMs are trained on trillions of tokens of Python, TypeScript, and JavaScript. A MojoBench study (NAACL 2025) found that even fine-tuned models achieved only 30–35% improvement over base models on Mojo code generation, illustrating the cold-start problem for new languages. -Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (243 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification. +Vera's approach has three parts. First, the agent-facing documentation (SKILL.md) is designed to be dropped into a model's context window, so the model works from the language specification rather than training data recall. Second, Vera's syntax is deliberately simple and regular — fewer constructs, each with exactly one canonical form — which reduces the surface area a model needs to learn. Third, the conformance test suite (244 programs covering every language feature) gives models concrete examples to learn from and conform to. Simon Willison's December 2025 JustHTML write-up illustrates the same point in practice: an LLM-assisted implementation, guided by the html5lib conformance suite, conformed to the HTML parsing spec by running against its tests — a comprehensive test suite is a strong scaffold for a model implementing to a specification. ## How does Vera compare to Dafny / Lean / Koka / F*? @@ -3265,7 +3265,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 11,670 tests, including a 243-program conformance suite +- 11,786 tests, including a 244-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/docs/llms.txt b/docs/llms.txt index afb6e578..2989765e 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -56,4 +56,4 @@ Current version: 0.1.11. The reference compiler is written in Python. Install th - [TESTING.md](https://raw.githubusercontent.com/aallan/vera/main/TESTING.md): Test suite architecture, coverage data, and test conventions. - [KNOWN_ISSUES.md](https://raw.githubusercontent.com/aallan/vera/main/KNOWN_ISSUES.md): Known bugs and limitations. - [CONTRIBUTING.md](https://raw.githubusercontent.com/aallan/vera/main/CONTRIBUTING.md): Contribution guidelines. -- [Conformance Suite](https://github.com/aallan/vera/tree/main/tests/conformance): 243 programs validating every language feature against the spec. +- [Conformance Suite](https://github.com/aallan/vera/tree/main/tests/conformance): 244 programs validating every language feature against the spec. diff --git a/spec/01-lexical-structure.md b/spec/01-lexical-structure.md index cdc8a6c9..4a57b425 100644 --- a/spec/01-lexical-structure.md +++ b/spec/01-lexical-structure.md @@ -51,7 +51,7 @@ A label written on a **function parameter** or on the **return slot** is preserv ## 1.4 Keywords -The following identifiers are reserved keywords and MUST NOT be used as type names or function names: +The following identifiers are reserved keywords and MUST NOT be used as function names: ``` @@ -60,10 +60,17 @@ match data type module import public private requires ensures invariant decreases assert assume effect handle resume with in forall exists -where true false pure +where true false pure ability +effects op old new result ``` -`handle` is the one exception, and only for function names: `public fn handle(@Request -> @Response)` is the entry point a *host* invokes under `vera serve` and `wasi:http` (Chapter 9, Section 9.5.6), so a function of that name is not dead code and stays legal. A future host-invoked entry point is exempted on the same grounds; nothing else is. Chapter 5, Section 5.2 gives the full reasoning and the **E153** rule. +The reservation is enforced by **E153** at the declaration, and the compiler derives the list above from the grammar itself rather than from a second hand-maintained copy, so a keyword cannot be reserved in this chapter and admitted by the checker. `resume` is the one entry that is not a grammar keyword; it is reserved on separate grounds, given in Chapter 5, Section 5.2. + +The restriction is on function names alone. Type names cannot collide with a keyword in the first place: every type-namespace binder in the grammar — data types, type aliases, constructors, effects, abilities, and `forall` type parameters — is an `UPPER_IDENT`, and every keyword is lowercase, so `data with` is refused as a malformed type name rather than as a reserved one. + +The restriction is also on the whole identifier, not on a prefix: `older`, `renew`, `matched`, `with_it` and `then_value` are ordinary function names. + +`handle` is the one exception. `public fn handle(@Request -> @Response)` is the entry point a *host* invokes under `vera serve` and `wasi:http` (Chapter 9, Section 9.5.6), so a function of that name is not dead code and stays legal. A future host-invoked entry point is exempted on the same grounds; nothing else is. Chapter 5, Section 5.2 gives the full reasoning and the **E153** rule. ## 1.5 Operators and Punctuation diff --git a/spec/05-functions.md b/spec/05-functions.md index ac45f89e..08237006 100644 --- a/spec/05-functions.md +++ b/spec/05-functions.md @@ -27,15 +27,19 @@ private fn function_name(@ParamType1, @ParamType2 -> @ReturnType) } ``` -An identifier the grammar claims in expression position is unavailable as a function name. Two groups are affected, and a third name is reserved for a different reason. +An identifier the grammar claims is unavailable as a function name. Three groups are affected, and a fourth name is reserved for a different reason. The first is the contract state forms `old` and `new` — in expression position `old(...)` and `new(...)` name an effect's state before and after a call, and take an effect reference rather than an arbitrary expression (Chapter 7, Section 7.9.2). A bare call written `old(x)` is therefore always read as a state reference, never as a function call. The second is the keywords the lexer admits as a name after `fn` but reads as the keyword everywhere else: `assert`, `assume`, `forall`, `exists`, `match`, `if`, `let`, `fn`, `true`, and `false`. A body containing `match(x)` does not parse as a call at all. +The third is the remaining keywords, which the *contextual* lexer admits wherever a name is expected: `then`, `else`, `data`, `type`, `module`, `import`, `public`, `private`, `requires`, `ensures`, `invariant`, `decreases`, `effect`, `with`, `in`, `where`, `pure`, `ability`, `effects`, `op`, and `result`. These differ from the first two groups in that nothing stops a call reaching them — `private fn with(@Int -> @Int)` declares, and `with(1)` resolves to it. They are reserved because Chapter 1, Section 1.4 reserves the identifier: a keyword names one construct, and a second meaning for the same spelling — the language's construct in one position, a user function in another — is what the one-canonical-form rule excludes. Because reachability is not the argument here, the compiler derives this group from the grammar rather than from a list, which is why it covers `ability`, `effects`, `op` and `result`, and why a keyword added to the grammar joins it automatically. + `resume` is reserved on separate grounds. It is not a keyword — a declaration parses, and outside a handler clause a bare `resume(...)` reaches it — but inside every handler clause body `resume` names the operator that resumes the suspended operation (Chapter 7, Section 7.5.2), bound there rather than declared. A function of that name would give one spelling two meanings by position, which the one-canonical-form rule does not admit. Chapter 1, Section 1.4 lists the identifier as reserved; **E153** enforces it, at the declaration and alone — a clause body in the same file still resolves `resume` to the operator, so the rejected declaration draws no second error. Resuming inside a handler clause is unaffected. -In both of the first two groups the declaration parses and no bare call can reach it: a function under such a name cannot be called from its own file, and a module cannot call its own export. The only route that reaches one is a module-qualified call (`mod::old(...)`, Chapter 8), which parses through the module-call rule — leaving the name a trap in every unqualified position and half-usable cross-module. Vera reserves the whole identifier instead: declaring a function under any of these names is a compile error (**E153**); rename the function. The restriction is on the whole identifier, so names that merely begin with a reserved word — `older`, `renew`, `matched` — are ordinary function names. +In both of the first two groups the declaration parses and no bare call can reach it: a function under such a name cannot be called from its own file, and a module cannot call its own export. The only route that reaches one is a module-qualified call (`mod::old(...)`, Chapter 8), which parses through the module-call rule — leaving the name a trap in every unqualified position and half-usable cross-module. Vera reserves the whole identifier instead. In the third group the declaration is reachable and the program works; what the reservation removes there is the second meaning, not a trap. + +Declaring a function under any name in any of the three groups, or under `resume`, is a compile error (**E153**); rename the function. Because the groups are reserved for different reasons, the diagnostic explains the one that applies — a reader told that `with` can never be called, when their own program just called it, would be misled. The restriction is on the whole identifier, so names that merely begin with a reserved word — `older`, `renew`, `matched` — are ordinary function names. `handle` is the one exception. It is a keyword, and equally uncallable from Vera source, but `public fn handle(@Request -> @Response)` is the entry point a *host* invokes under `vera serve` and `wasi:http` (Chapter 9, Section 9.5.6), so it is not dead code and stays legal. A future host-invoked entry point is exempted on the same grounds; nothing else is. diff --git a/tests/conformance/ch05_reserved_contextual_keyword_fn_rejected.vera b/tests/conformance/ch05_reserved_contextual_keyword_fn_rejected.vera new file mode 100644 index 00000000..ceef2313 --- /dev/null +++ b/tests/conformance/ch05_reserved_contextual_keyword_fn_rejected.vera @@ -0,0 +1,27 @@ +-- Conformance: a contextual keyword is not available as a function name (Chapter 5, Section 5.2) +-- Tests: E153 — a user fn named after a grammar keyword the contextual lexer +-- admits as a name is rejected at its declaration (#1296). This branch of the +-- gate is the one whose names are NOT traps: before the reservation this exact +-- program type-checked, verified, compiled and ran, and a bare `with(1)` +-- resolved to the declaration and returned its value. Spec Chapter 1, +-- Section 1.4 reserves the identifier all the same, and nothing held the MUST +-- — the spec and the implementation disagreed about which programs are legal. +-- The reserved set is derived from `vera/grammar.lark` rather than hand-listed, +-- so `ability`, `effects`, `op` and `result` are covered on the same rule even +-- though Section 1.4 never listed them. `handle` stays legal: it is the +-- host-invoked `vera serve` entry point (Chapter 9, Section 9.5.6). +public fn with(@Int -> @Int) + requires(true) + ensures(@Int.result >= 0) + effects(pure) +{ + 5 +} + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ + 0 +} diff --git a/tests/conformance/manifest.json b/tests/conformance/manifest.json index db8ab090..dad353fa 100644 --- a/tests/conformance/manifest.json +++ b/tests/conformance/manifest.json @@ -962,6 +962,20 @@ "check_error" ] }, + { + "id": "ch05_reserved_contextual_keyword_fn_rejected", + "file": "ch05_reserved_contextual_keyword_fn_rejected.vera", + "chapter": 5, + "title": "A function named after a contextual grammar keyword is a checker error (#1296)", + "level": "check", + "spec_ref": "Section 5.2", + "expected_error": "E153", + "features": [ + "reserved_fn_name", + "function_declaration", + "check_error" + ] + }, { "id": "ch05_reserved_resume_fn_rejected", "file": "ch05_reserved_resume_fn_rejected.vera", diff --git a/tests/test_checker_modules.py b/tests/test_checker_modules.py index 4aaf3f72..d7c64bca 100644 --- a/tests/test_checker_modules.py +++ b/tests/test_checker_modules.py @@ -1353,11 +1353,21 @@ class TestReservedFnName: * ``old``, ``new`` — declaration accepted, call rejected (E030 / E031). Reserved here, as ``_STATE_FORM_FN_NAMES``. - * ``throw``, ``with``, ``in``, ``effect``, ``op``, ``data``, ``type``, - ``import``, ``public``, ``private``, ``requires``, ``ensures``, - ``effects``, ``decreases``, ``where``, ``then``, ``else``, ``pure``, - ``invariant`` — declaration *and* call both accepted. Not reserved; - nothing is wrong with them. + * ``with``, ``in``, ``effect``, ``op``, ``data``, ``type``, ``import``, + ``public``, ``private``, ``requires``, ``ensures``, ``effects``, + ``decreases``, ``where``, ``then``, ``else``, ``pure``, ``invariant``, + ``module``, ``ability``, ``result`` — declaration *and* call both + accepted, and this row long read "Not reserved; nothing is wrong with + them". Something is: spec §1.4 reserves them and nothing held the + MUST, so the specification and the implementation disagreed about + which programs are legal (#1296). Being callable is what removed the + *unreachability* argument, not the reservation. They are reserved as + ``_CONTEXTUAL_KEYWORD_FN_NAMES`` — derived from ``grammar.lark`` rather + than listed, which is how ``ability``/``effects``/``op``/``result`` + joined despite §1.4 never naming them — and + :class:`TestReservedContextualKeywordFnName` owns that piece. + (``throw`` was on this row and is not a keyword in the grammar at all, + so it stays an ordinary function name.) * ``resume`` — declaration and call both accepted here too, which is why this probe row once read "nothing is wrong with it". Something is: the accepted declaration collides with the resumption binding every handler @@ -2104,6 +2114,304 @@ def test_resume_set_is_its_own_named_piece(self) -> None: assert "resume" in _RESERVED_FN_NAMES +# ===================================================================== +# Reserved CONTEXTUAL keyword function names (E153) — #1296 +# ===================================================================== + +class TestReservedContextualKeywordFnName: + """A ``fn`` named after a *contextual* grammar keyword is rejected + (E153, #1296). + + The fourth piece of :data:`_RESERVED_FN_NAMES`, and the one whose + members are **not** declarable traps. Every name here declares, type + checks, verifies, compiles, runs, and round-trips ``vera fmt``; a + bare call reaches it and returns its value. Lark's contextual lexer + admits the spelling as ``LOWER_IDENT`` wherever a name is expected and + reads it as the keyword only where the keyword's own construct is being + parsed, so nothing collides. + + **Probe record** (run against the pre-#1296 tree, ``private fn + (@Int -> @Int)`` declared and called from ``main``; 21 names × + six positions plus four interaction shapes): + + * All 21 — declaration accepted, bare call accepted, ``vera verify`` + proves the contracts, ``vera run`` returns the computed value, and + ``vera fmt --check`` is clean. Still accepted when called from + inside a contract clause, from an ``if``/``then``/``else`` branch, + from a function that itself carries a ``where { }`` block, and after + a ``let``. No positional ambiguity: unlike ``resume`` these do not + shadow an injected binding, and unlike ``match`` they parse at a call + site. + * ``data`` / ``type`` / constructor positions — refused ``[E005]`` for + every name, but by the *case* rail rather than by any reservation: + the grammar binds every type-namespace name as ``UPPER_IDENT`` and + every keyword is lowercase, so spec §1.4's "type names" half is + vacuous by construction and only the function-name half can be + violated. Pinned by ``test_type_namespace_half_is_vacuous``. + + So the reservation cannot rest on unreachability — that claim is false + for all 21 — and this branch must never reuse the #1187 wording. It + rests on spec §1.4 reserving the identifier, DESIGN principle 1 + (an unenforced MUST is a spec/implementation divergence, whatever the + program does at runtime) and principle 6 (fewer valid programs). + ``test_rationale_makes_no_unreachability_claim`` is the pin. + + **Derived, not hand-listed.** The set comes from ``vera/grammar.lark`` + itself, the shape :func:`builtin_effect_names` already uses for E152, so + a keyword added to the grammar is gated the moment it is added. Four of + the 21 — ``ability``, ``effects``, ``op`` and ``result`` — are grammar + keywords spec §1.4 never listed, and were found by the derivation rather + than by the issue. ``test_reserved_set_is_derived_from_the_grammar`` + pins the derivation against the grammar file. + """ + + #: The names this branch newly reserves (all 21; ``handle`` excluded as + #: the host-invoked carve-out, and the #1187/#1181 pieces excluded as + #: they keep their own rationales). + CONTEXTUAL = ( + # The seventeen spec §1.4 lists and nothing enforced (#1296). + "then", "else", "data", "type", "module", "import", "public", + "private", "requires", "ensures", "invariant", "decreases", + "effect", "with", "in", "where", "pure", + # Four the grammar reserves that spec §1.4 never listed. + "ability", "effects", "op", "result", + ) + + #: Wording from the #1187 keyword branch that is FALSE for these names. + FALSE_CLAIMS = ( + "no unqualified call site can reach", + "does not parse as a call", + "could never be called", + "always lexed as the keyword", + "dead code", + ) + + @staticmethod + def _codes(errs: list[Diagnostic]) -> list[str]: + return [e.error_code for e in errs] + + def test_reserved_set_is_derived_from_the_grammar(self) -> None: + """The reservation is computed from ``grammar.lark``, not hand-listed. + + Reads the grammar file independently of the checker and asserts that + every identifier-shaped string literal it claims — minus the + host-invoked carve-out — is reserved. This is the mutation-catching + pin: replacing the derivation with a hand-list and dropping any one + keyword fails here, which is exactly how #1296 arose (a hand-list + that had silently fallen 21 names behind the grammar). + + ``_`` is excluded because the wildcard pattern is not a valid + ``LOWER_IDENT`` and so can never be a function name. + """ + import re + + from vera.checker.registration import ( + _HOST_INVOKED_FN_NAMES, + _RESERVED_FN_NAMES, + ) + from vera.parser import _GRAMMAR_PATH + + src = re.sub(r"//[^\n]*", "", _GRAMMAR_PATH.read_text(encoding="utf-8")) + literals = { + lit for lit in re.findall(r'"([A-Za-z_][A-Za-z0-9_]*)"', src) + if re.fullmatch(r"[a-z][A-Za-z0-9_]*", lit) + } + # The grammar really does claim these, so the pin has teeth. + assert {"with", "where", "op", "result"} <= literals, sorted(literals) + missing = (literals - _HOST_INVOKED_FN_NAMES) - _RESERVED_FN_NAMES + assert missing == set(), sorted(missing) + + def test_contextual_tuple_matches_checker_set(self) -> None: + """``CONTEXTUAL`` mirrors the checker's contextual piece exactly. + + The sibling of ``test_keyword_tuple_matches_checker_set``: a name + entering the derived set without a per-name cell here fails this pin + instead of silently escaping coverage. + """ + from vera.checker.registration import _CONTEXTUAL_KEYWORD_FN_NAMES + + assert set(self.CONTEXTUAL) == _CONTEXTUAL_KEYWORD_FN_NAMES + + @pytest.mark.parametrize("name", CONTEXTUAL) + def test_contextual_keyword_fn_name_is_E153(self, name: str) -> None: + """Each contextual keyword is refused at the declaration site, + fully tagged per spec §0.5.1.""" + errs = _errors(f""" +public fn {name}(@Int -> @Int) + requires(true) ensures(@Int.result >= 0) effects(pure) +{{ 5 }} +""") + assert "E153" in self._codes(errs), (name, self._codes(errs)) + diag = next(e for e in errs if e.error_code == "E153") + assert name in diag.description, diag.description + assert "reserved" in diag.description.lower(), diag.description + assert diag.rationale and diag.fix and diag.spec_ref + assert "Chapter 5" in diag.spec_ref, diag.spec_ref + assert "rename" in diag.fix.lower(), diag.fix + + @pytest.mark.parametrize("name", CONTEXTUAL) + def test_private_contextual_keyword_fn_name_is_E153( + self, name: str, + ) -> None: + """Visibility-independent, as every other branch is.""" + errs = _errors(f""" +private fn {name}(@Int -> @Int) + requires(true) ensures(@Int.result >= 0) effects(pure) +{{ 5 }} +""") + assert "E153" in self._codes(errs), (name, self._codes(errs)) + + @pytest.mark.parametrize("name", CONTEXTUAL) + def test_where_helper_contextual_keyword_is_E153(self, name: str) -> None: + """The where-helper recursion covers this branch too. + + The pre-fix sweep found the helper position mirroring the top-level + one for all 21 (declared, called, ran), so the gate must reach it + identically or the reservation is half-applied. + """ + errs = _errors(f""" +public fn caller(@Int -> @Int) + requires(true) ensures(@Int.result >= 0) effects(pure) +{{ @Int.0 }} +where {{ + fn {name}(@Int -> @Int) + requires(true) ensures(true) effects(pure) + {{ @Int.0 }} +}} +""") + assert "E153" in self._codes(errs), (name, self._codes(errs)) + + @pytest.mark.parametrize("name", CONTEXTUAL) + def test_rationale_makes_no_unreachability_claim(self, name: str) -> None: + """CRITICAL: the branch must not ship the #1187 wording. + + Every phrase in ``FALSE_CLAIMS`` is true of ``match`` and false of + these names — each one is callable, and the pre-fix probe ran them. + A diagnostic asserting otherwise would tell the reader a falsehood + about their own program, which the diagnostic-fields contract + (spec §0.5.1, #955) does not waive for any field. + """ + diag = next( + e for e in _errors(f""" +public fn {name}(@Int -> @Int) + requires(true) ensures(true) effects(pure) +{{ 5 }} +""") if e.error_code == "E153" + ) + text = f"{diag.rationale} {diag.fix}".lower() + for claim in self.FALSE_CLAIMS: + assert claim not in text, (name, claim, diag.rationale) + # It must still say WHY: the identifier is reserved by the spec. + assert "reserved" in text, (name, diag.rationale) + # And it must not borrow either sibling branch's explanation. + assert "state form" not in diag.rationale.lower(), diag.rationale + assert "resumes a suspended" not in diag.rationale.lower(), ( + diag.rationale + ) + + @pytest.mark.parametrize("name", CONTEXTUAL) + def test_fix_suggests_a_usable_replacement(self, name: str) -> None: + """The fix names a concrete replacement that is not itself reserved. + + DESIGN principle 1 asks for "an instruction, not a status report". + A bare ``{name}_fn`` template produces ``in_fn`` / ``type_fn`` / + ``pure_fn``, which is advice no author would take, so the branch + carries a per-name suggestion where the generic suffix misleads. + Pinned by property — the suggested identifier must be a legal Vera + function name and must not be reserved — rather than by exact + wording, so the table can be improved without churning the test. + """ + import re + + from vera.checker.registration import ( + _builtin_reject_names, + _RESERVED_FN_NAMES, + ) + + diag = next( + e for e in _errors(f""" +public fn {name}(@Int -> @Int) + requires(true) ensures(true) effects(pure) +{{ 5 }} +""") if e.error_code == "E153" + ) + quoted = re.findall(r"'([a-z][A-Za-z0-9_]*)'", diag.fix) + suggestions = [q for q in quoted if q != name] + assert suggestions, diag.fix + for s in suggestions: + # Not reserved (E153 again) and not a built-in (E151 instead) — + # advice that trades one error for another is not a fix. + assert s not in _RESERVED_FN_NAMES, (name, s, diag.fix) + assert s not in _builtin_reject_names(), (name, s, diag.fix) + + def test_handle_stays_legal(self) -> None: + """NEGATIVE CONTROL: the carve-out survives a derived set. + + Deriving from the grammar pulls ``handle`` in with every other + keyword, so the subtraction is what keeps ``vera serve`` its entry + point. If the derivation ever forgets it, ``examples/http_server + .vera`` and ``ch09_http_server`` break together. + """ + errs = _errors(""" +public fn handle(@Request -> @Response) + requires(true) ensures(true) effects() +{ + match @Request.0 { + Request(@String, @String, @Map, @String) -> + Response(200, map_new(), @String.0) + } +} +""") + assert self._codes(errs) == [], self._codes(errs) + + def test_names_merely_containing_a_contextual_keyword_are_allowed( + self, + ) -> None: + """NEGATIVE CONTROL: the reservation is the whole identifier. + + A substring test would reject a large share of ordinary Vera — + ``with_it``, ``then_value`` and ``older`` are unremarkable function + names, and ``in`` is a substring of a great many words. + """ + for name in ( + "then_value", "older", "with_it", "invariants", "typed", + "public_key", "purity", "wherever", "import_path", "results", + "operation", "effective", "ability_of", "indexed", "dataset", + ): + errs = _errors(f""" +public fn {name}(@Int -> @Int) + requires(true) ensures(@Int.result >= 0) effects(pure) +{{ 5 }} + +public fn main(@Unit -> @Int) + requires(true) ensures(true) effects(pure) +{{ {name}(3) }} +""") + assert self._codes(errs) == [], (name, self._codes(errs)) + + def test_type_namespace_half_is_vacuous(self) -> None: + """Spec §1.4's "type names" half cannot be violated by construction. + + Every type-namespace binder in the grammar is ``UPPER_IDENT`` and + every keyword is lowercase, so ``data with`` / ``type with = Int;`` + fail at *parse* — and would fail identically for any lowercase name. + Pinned so the spec's corrected wording ("function names") stays + backed by the grammar, and so a future grammar change admitting a + lowercase type name shows up here rather than reopening the hole. + """ + for src in ("private data with {\n MkX(Int)\n}\n", + "type with = Int;\n", + "private data Holder {\n with(Int)\n}\n"): + with pytest.raises(ParseError): + parse_to_ast(src) + # Control: an ordinary LOWERCASE name fails the same way, proving the + # rejection is the case rail and not the reservation. + for src in ("private data helper {\n MkX(Int)\n}\n", + "type helper = Int;\n"): + with pytest.raises(ParseError): + parse_to_ast(src) + + # ===================================================================== # Module-qualified call parse tests (#95) # ===================================================================== diff --git a/vera/README.md b/vera/README.md index 2be365f7..98bdd1c2 100644 --- a/vera/README.md +++ b/vera/README.md @@ -85,7 +85,7 @@ execute(compile_result, ...) # → run WASM via wasmtime | ` core.py` | 1,165 | | TypeChecker class, orchestration, contracts, constraint validation | | | ` resolution.py` | 535 | | AST TypeExpr → semantic Type, inference | | | ` modules.py` | 476 | | Cross-module registration (C7b/C7c), plus the per-module body check that makes a module's diagnostics independent of which file `vera check` was given (#1244) and the #1304 refusal of a bare function, data-type or constructor name two imports both supply (E155/E156/E157) | | -| ` registration.py` | 915 | | Pass 1 forward declarations, ability registration | | +| ` registration.py` | 1,032 | | Pass 1 forward declarations, ability registration | | | ` expressions.py` | 1,485 | | Expression synthesis (bidirectional), operators, statements | | | ` eq_ability.py` | 199 | | Eq ability derivation checks | | | ` sql.py` | 309 | | SQL literal-provenance resolution + placeholder counting (#309) | `resolve_literal_string()`, `count_placeholders()` | @@ -590,7 +590,7 @@ The WASM import interface is the portability contract: the compiled `.wasm` bina ### Browser runtime -`browser/runtime.mjs` is a self-contained JavaScript runtime (~3,877 lines) that provides JavaScript implementations of all Vera host bindings. It works with **any** compiled Vera `.wasm` module — no code generation needed. +`browser/runtime.mjs` is a self-contained JavaScript runtime (~3,877 lines) that provides JavaScript implementations of all Vera host bindings. It works with any core Vera `.wasm` module — the default and browser targets share one import ABI, so no code generation is needed; the `--target wasi-p2` component is a different artifact format with its own host. **Dynamic import introspection:** Instead of generating per-program glue code, the runtime uses `WebAssembly.Module.imports(module)` at initialization to discover which host functions the module actually needs, then builds the import object dynamically. State\ types are pattern-matched from `state_get_*`/`state_put_*` import names. @@ -753,7 +753,7 @@ The `ERROR_CODES` dict in `errors.py` maps every code to a short description (16 ## Test Suite -Testing spans a **pytest suite** of 11,670 tests across 174 files — compiler-internals unit tests plus a **conformance suite** (243 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. +Testing spans a **pytest suite** of 11,786 tests across 174 files — compiler-internals unit tests plus a **conformance suite** (244 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. See **[TESTING.md](../TESTING.md)** for the comprehensive testing reference -- test file table, conformance suite details, compiler code coverage, language feature coverage, helper conventions, validation scripts, CI pipeline, and guidelines for adding tests. diff --git a/vera/checker/registration.py b/vera/checker/registration.py index b7c107c1..6c883641 100644 --- a/vera/checker/registration.py +++ b/vera/checker/registration.py @@ -69,17 +69,21 @@ def builtin_effect_names() -> frozenset[str]: return _registry_names() -# Identifiers the grammar reserves in *expression* position, so a call to a -# same-named function can never parse (E153). A function under one of them is -# a declarable trap: it declares cleanly and no bare call site can reach it. -# The reservation refuses the mistake at its source rather than letting it -# surface later as a call-site error, the same one-canonical-form rule as E151 -# (built-in functions) and E152 (built-in effects). +# Identifiers unavailable as function names (E153). For pieces 1 and 2 the +# grammar claims the spelling in *expression* position, so a function under one +# of those names is a declarable trap: it declares cleanly and no bare call site +# can reach it. The reservation refuses the mistake at its source rather than +# letting it surface later as a call-site error, the same one-canonical-form +# rule as E151 (built-in functions) and E152 (built-in effects). # -# The set is assembled from four named pieces so a future addition joins the -# right one deliberately. Piece 3 is the exception to the paragraph above: its -# name *is* reachable from expression position, and is reserved because that -# reachability collides with a binding the checker injects. +# The set is assembled from five named pieces so a future addition joins the +# right one deliberately, and each piece carries its own rationale because they +# are reserved for genuinely different reasons. Pieces 3 and 5 are the +# exceptions to the paragraph above — both ARE reachable from expression +# position. Piece 3 is reserved because that reachability collides with a +# binding the checker injects; piece 5 because spec §1.4 reserves the +# identifier and a keyword must not acquire a second meaning by position. +# Only pieces 1 and 2 may claim unreachability in a diagnostic. # 1. The two contract state forms (#1181). ``old_expr`` and ``new_expr`` in # ``vera/grammar.lark`` claim ``"old" "("`` and ``"new" "("``, and each demands @@ -92,12 +96,17 @@ def builtin_effect_names() -> frozenset[str]: # expression position: a bare ``match(3)`` does not parse at all (``[E005]``), # and ``assert(3)`` / ``assume(3)`` are read as the statement forms and collide # (``[E121]`` + ``[E172]``/``[E173]``). Membership is decided by what the -# *lexer* does with the name, and it splits the rest of spec §1.4's keyword -# list two ways. ``with``, ``effect``, ``data``, ``type`` and their kind are -# refused at parse: the contextual lexer does not admit them as a function -# name, so no declaration reaches this checker at all. ``resume`` is refused -# by neither — it is not a keyword token anywhere — and is reserved below on -# its own grounds, by the checker rather than the parser. +# *lexer* does with the name — these are the keywords a call site genuinely +# cannot reach, which is what their rationale below claims. ``resume`` is a +# keyword token nowhere and is reserved by piece 3; every OTHER spec §1.4 +# keyword is reachable and is reserved by piece 5. +# +# This set was once described as covering the whole keyword list, on the +# premise that ``with`` / ``effect`` / ``data`` / ``type`` and their kind were +# "refused at parse: the contextual lexer does not admit them as a function +# name". The tree refuted that premise (#1296): all 21 such names declared, +# type checked, verified, compiled and RAN. They are now reserved by piece 5, +# which argues from the specification rather than from reachability. _KEYWORD_FN_NAMES = frozenset({ "assert", "assume", "forall", "exists", "match", "if", "let", "fn", "true", "false", "handle", @@ -131,6 +140,83 @@ def builtin_effect_names() -> frozenset[str]: # justification — rather than being dropped from the keyword list above. _HOST_INVOKED_FN_NAMES = frozenset({"handle"}) + +@functools.lru_cache(maxsize=1) +def grammar_keyword_names() -> frozenset[str]: + """Every keyword ``vera/grammar.lark`` claims as a bare string literal. + + Read from the grammar file itself (:data:`vera.parser._GRAMMAR_PATH`, the + same one the parser is built from), never a hand-list, so a keyword added + to the grammar is reserved the moment it is added. This is the shape + :func:`builtin_effect_names` already uses for E152, adopted here for the + same reason: the hand-list this replaces had silently fallen 21 names + behind the grammar (#1296), and no gate could see the drift. + + Filtered to identifiers the lexer could actually produce — ``LOWER_IDENT`` + is ``/[a-z][A-Za-z0-9_]*/``, so the wildcard pattern ``"_"`` is excluded + as it can never be a function name. Line comments are stripped first so a + keyword mentioned only in prose is not picked up. + """ + from vera.parser import _GRAMMAR_PATH + + src = re.sub(r"//[^\n]*", "", _GRAMMAR_PATH.read_text(encoding="utf-8")) + return frozenset( + lit for lit in re.findall(r'"([A-Za-z_][A-Za-z0-9_]*)"', src) + if re.fullmatch(r"[a-z][A-Za-z0-9_]*", lit) + ) + + +# 5. The *contextual* keywords: every remaining name the grammar claims (#1296). +# Unlike pieces 1-3 these are not traps. Lark's contextual lexer admits each +# as a name wherever a name is expected and reads it as the keyword only while +# the keyword's own construct is being parsed, so `private fn with(@Int -> +# @Int)` declares, type checks, verifies, compiles, runs, and answers a bare +# `with(1)` — and stays working inside a contract clause, inside an +# `if`/`then`/`else`, in a function carrying a `where { }` block, and after a +# `let`. Nothing about the program breaks. +# +# What breaks is the specification. Spec §1.4 says these identifiers MUST NOT +# be used as function names and nothing held the MUST, so the spec and the +# implementation disagreed about which programs are legal — a model trusting +# §1.4 and a model trusting the compiler derive different programs from the +# same source of truth, and no tool contradicted either. DESIGN principle 1 +# ("checkability over correctness") makes that a defect whatever the program +# does at runtime; principle 6 ("fewer valid programs") chooses enforcement +# over narrowing §1.4; principle 3 supplies the precedent, E152 rejecting even +# a FAITHFUL re-declaration of a built-in effect because a second textual +# spelling is itself the problem. +# +# Derived rather than listed, so the drift cannot recur. Four of the names +# this reserves — `ability`, `effects`, `op`, `result` — are grammar keywords +# spec §1.4 never listed, found by the derivation rather than by the issue. +# A future grammar keyword lands here by default, which is the safe branch: its +# rationale argues from the reservation, which is true of every reserved +# keyword, rather than from unreachability, which is what proved false. +_CONTEXTUAL_KEYWORD_FN_NAMES = ( + grammar_keyword_names() + - _STATE_FORM_FN_NAMES + - _KEYWORD_FN_NAMES + - _HANDLER_OPERATOR_FN_NAMES + - _HOST_INVOKED_FN_NAMES +) + +# A concrete rename for each, because the generic `_fn` template produces +# `in_fn` / `type_fn` / `pure_fn` — advice no author would take, where DESIGN +# principle 1 asks for "an instruction, not a status report". A name absent +# here falls back to the template, so a future grammar keyword still gets a +# usable fix; none of these collides with a built-in (E151) or another +# reserved name, which `test_fix_suggests_a_usable_replacement` pins. +_CONTEXTUAL_RENAME_HINTS = { + "then": "then_branch", "else": "else_branch", "data": "payload", + "type": "type_of", "module": "module_name", "import": "import_path", + "public": "is_public", "private": "is_private", + "requires": "precondition", "ensures": "postcondition", + "invariant": "invariant_of", "decreases": "measure", + "effect": "effect_of", "with": "combined_with", "in": "contains", + "where": "matching", "pure": "is_pure", "ability": "ability_of", + "effects": "effect_row", "op": "operation", "result": "result_of", +} + # One route did reach a reserved name before it was reserved: a module-qualified # ``mod::old(...)`` / ``mod::match(...)`` parses through the module-call rule # rather than any reserved rule, so a module export under one of these names was @@ -138,7 +224,8 @@ def builtin_effect_names() -> frozenset[str]: # route deliberately — a name that is a trap in every unqualified position is # reserved outright rather than left half-usable. _RESERVED_FN_NAMES = ( - (_STATE_FORM_FN_NAMES | _KEYWORD_FN_NAMES | _HANDLER_OPERATOR_FN_NAMES) + (_STATE_FORM_FN_NAMES | _KEYWORD_FN_NAMES | _HANDLER_OPERATOR_FN_NAMES + | _CONTEXTUAL_KEYWORD_FN_NAMES) - _HOST_INVOKED_FN_NAMES ) @@ -459,19 +546,22 @@ def _check_reserved_type_params( def _check_reserved_fn_name(self, decl: ast.FnDecl) -> None: """Emit E153 if ``decl`` — or a nested where-helper — is named after a - contract state form (#1181), a grammar keyword (#1187), or the - handler-clause resumption operator. + contract state form (#1181), an unreachable grammar keyword (#1187), + the handler-clause resumption operator, or a contextual grammar + keyword (#1296). Recurses into ``where_fns``: a helper is called in expression position exactly like a top-level function, so a helper named ``old`` or - ``match`` is unreachable for the same reason, one scope deeper, and a - helper named ``resume`` collides with the same injected binding. + ``match`` is unreachable for the same reason, one scope deeper, a + helper named ``resume`` collides with the same injected binding, and a + helper named ``with`` is the same second spelling one scope in. The rationale branches on which piece of :data:`_RESERVED_FN_NAMES` - the name came from — the three are reserved for different reasons, and - telling a reader that ``match`` is a "contract state form", or that - ``resume`` is a keyword no call site can reach, would be false. The - fix is the same on every branch: rename. + the name came from — the four are reserved for different reasons, and + telling a reader that ``match`` is a "contract state form", that + ``resume`` is a keyword no call site can reach, or that ``with`` is + unreachable when their own program just called it, would each be + false. The fix is the same on every branch: rename. The rejected declaration is still registered, unlike E151's. There is no canonical built-in for the name to shadow here — nothing can resolve @@ -524,6 +614,33 @@ def _check_reserved_fn_name(self, decl: ast.FnDecl) -> None: f"Resuming inside a handler clause is unaffected: that " f"'{n}' is bound by the handler, not declared." ) + elif n in _CONTEXTUAL_KEYWORD_FN_NAMES: + hint = _CONTEXTUAL_RENAME_HINTS.get(n, f"{n}_fn") + rationale = ( + f"'{n}' is a keyword of the language: the grammar claims " + f"the spelling for its own construct, and Chapter 1, " + f"Section 1.4 reserves the identifier. Unlike the other " + f"reserved names this one is reachable — the contextual " + f"lexer admits '{n}' as a name where a name is expected, " + f"so the declaration parses and a call resolves to it. " + f"That is what makes it worth refusing rather than " + f"tolerating: the same spelling would name a language " + f"construct in one place and this function in another, " + f"and a reader would have to decide which by position. " + f"Vera provides exactly one way to express each " + f"construct, so a keyword names that construct and " + f"nothing else." + ) + fix = ( + f"Rename the function to an identifier that is not a " + f"keyword — '{hint}', or better a name describing what " + f"it computes — and update its call sites. The " + f"reservation is on the whole identifier, so a longer " + f"name that merely contains '{n}' (such as " + f"'{n}_value') is an ordinary function name. 'handle' is " + f"the one keyword still available, because 'vera serve' " + f"invokes it from the host rather than from Vera source." + ) else: rationale = ( f"'{n}' is a keyword the grammar reserves in expression " From 635593c78cda9a4d4026a761c28ac3e0f4354760 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 22:49:30 +0100 Subject: [PATCH 54/69] Count the stress marker's instances, not one file's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review's real Minor: the local-invocation comment said 16 where pytest -m stress collects 26 — test_stress.py's 16 plus TestHostHandleReclamation573's 10 in test_codegen_gc_reclamation.py, which joined the marker after the line was written. Measured by collection. Skip-changelog: one stale figure in a TESTING.md shell comment Co-Authored-By: Claude --- TESTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TESTING.md b/TESTING.md index af8bdbda..589ab258 100644 --- a/TESTING.md +++ b/TESTING.md @@ -763,7 +763,7 @@ The eager-GC lane is implemented via a `pytest.mark.parametrize("eager_gc", [Fal **Default behaviour**: stress tests are skipped from the per-PR pytest run via `addopts = "-m 'not stress'"` in `pyproject.toml`. Local invocation: ```bash -pytest -m stress # all 16 parametrised test instances (9 logical tests × eager-GC lane) +pytest -m stress # all 26 marker-carrying instances: test_stress.py's 16 (9 logical tests × eager-GC lane) + TestHostHandleReclamation573's 10 pytest tests/test_stress.py -m stress -v # full stress suite, verbose pytest tests/test_stress.py::test_array_map_over_10k_int_array -m stress -v # both modes of one test pytest "tests/test_stress.py::test_array_map_over_10k_int_array[eager_gc]" -m stress -v # one mode only From 9ab7172d104f6015968285d5695b4eef6ed3f4d5 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 23:47:36 +0100 Subject: [PATCH 55/69] Scope the stress budget figure to the workflow's suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review's chain-catch: line 766 now derives the marker's 26 while the Budget paragraph said "the full suite ... 16". The 16 is a dated measurement (0.66s, 2026-05-13) of the nightly workflow's actual invocation — pytest -m stress tests/test_stress.py, file-scoped — so the fix is scoping language naming where the other 10 instances run, not renumbering a measurement that never covered them. Also restores the "7 of them" eager-lane nuance the previous edit dropped. Skip-changelog: TESTING.md prose scoping only Co-Authored-By: Claude --- TESTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/TESTING.md b/TESTING.md index 589ab258..89f2f86a 100644 --- a/TESTING.md +++ b/TESTING.md @@ -763,7 +763,7 @@ The eager-GC lane is implemented via a `pytest.mark.parametrize("eager_gc", [Fal **Default behaviour**: stress tests are skipped from the per-PR pytest run via `addopts = "-m 'not stress'"` in `pyproject.toml`. Local invocation: ```bash -pytest -m stress # all 26 marker-carrying instances: test_stress.py's 16 (9 logical tests × eager-GC lane) + TestHostHandleReclamation573's 10 +pytest -m stress # all 26 marker-carrying instances: test_stress.py's 16 (9 logical tests, 7 with an eager-GC twin) + TestHostHandleReclamation573's 10 pytest tests/test_stress.py -m stress -v # full stress suite, verbose pytest tests/test_stress.py::test_array_map_over_10k_int_array -m stress -v # both modes of one test pytest "tests/test_stress.py::test_array_map_over_10k_int_array[eager_gc]" -m stress -v # one mode only @@ -777,7 +777,7 @@ pytest "tests/test_stress.py::test_array_map_over_10k_int_array[eager_gc]" -m st **Failure reporting (cron only)**: when the nightly cron fails, the workflow opens an issue titled "Nightly stress regression on main (tracking)" with the `stress-regression` label, including the commit SHA and the run URL. If an open issue with that label already exists, the new failure posts a comment on it instead of filing a duplicate — so the issue persists across days of failures until a maintainer manually closes it. The `stress-regression` label is auto-created on first failure. This converts cron failures from "visible only to whoever opens the Actions tab" to "visible in the issue feed where Vera work is already triaged." Implementation uses `actions/github-script@v9` with `issues: write` job-scoped permission. -**Budget**: the full suite completes in well under the 5-minute target — measured at **0.66s in-process** on a developer laptop on 2026-05-13 for all 16 test instances (9 logical × eager-GC lane on 7 of them). CI cold-start adds workflow setup time on top. Iteration counts are tuned to the smallest scale where each bug class has historically manifested with ~2-3x safety margin, NOT maximised — the goal is reliable detection of the bug class, not benchmarking. If this measured figure drifts more than ~2x in either direction, treat it as a signal: either iteration counts have grown without rationale (revisit per the "Adding a stress test" rule 2) or a runtime perf regression has landed. +**Budget**: the workflow's suite — `tests/test_stress.py` per the invocation above; the marker's other 10 instances in `test_codegen_gc_reclamation.py` ride the main per-PR suite — completes in well under the 5-minute target: measured at **0.66s in-process** on a developer laptop on 2026-05-13 for its 16 test instances (9 logical × eager-GC lane on 7 of them). CI cold-start adds workflow setup time on top. Iteration counts are tuned to the smallest scale where each bug class has historically manifested with ~2-3x safety margin, NOT maximised — the goal is reliable detection of the bug class, not benchmarking. If this measured figure drifts more than ~2x in either direction, treat it as a signal: either iteration counts have grown without rationale (revisit per the "Adding a stress test" rule 2) or a runtime perf regression has landed. **Assertion shape**: each test asserts on a SPECIFIC observable (e.g. `array_fold` returning the closed-form sum `4999950000`, `IO.print` producing exactly 10000 `x` characters), not just "completed without crashing". This catches a future regression where the loop silently short-circuits or skips iterations. From e0987ebc45eb23d32173e0d4e701d12a7954a0e2 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 23:55:06 +0100 Subject: [PATCH 56/69] State where the reclamation battery actually runs, and track the gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review's follow-through catch was right and mine was wrong: the previous commit said the 10 TestHostHandleReclamation573 instances "ride the main per-PR suite", but the per-PR addopts deselect the stress marker (they are the standing 26-deselected) and the nightly workflow is file-scoped to tests/test_stress.py — so no automated lane runs them, against the class comment's own "or nightly CI" intent. Filed as #1328 with a Bugs row; the stress section now states the explicit-invocation truth and cites it. Skip-changelog: TESTING/KNOWN_ISSUES prose; the tracked gap rides #1328 Co-Authored-By: Claude --- KNOWN_ISSUES.md | 1 + TESTING.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 2242a3a1..7d66d41f 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -8,6 +8,7 @@ Defects in shipped compiler, runtime, or tooling behaviour — this table matche | Bug | Issue | |-----|-------| +| The nightly stress workflow never exercises `TestHostHandleReclamation573` — its 10 stress-marked instances are deselected from the per-PR suite (`-m 'not stress'` addopts) and `.github/workflows/nightly-stress.yml` runs `pytest -v -m stress tests/test_stress.py`, file-scoped, so no automated lane runs the #573/#575/#576/#706 GC-reclamation battery; the class comment's "Run via `pytest -m stress` or nightly CI" states the unmet intent. Run `pytest -m stress` manually until the workflow adds the file or the comment is re-scoped. | [#1328](https://github.com/aallan/vera/issues/1328) | | Monomorphization's Vera-level type namer has no `IndexExpr` arm, so a generic argument that is an array index drops its caller. `idg(@Array.0[1])` — a `forall` identity called on an element of a local `Array` — is `vera check`-green and then dies at `vera run` with `[E602] Function 'main' body contains unsupported FnCall: call target 'idg$Int' not registered in this module`, leaving `Available exports: (none)`: instantiation discovery (`Monomorphizer._infer_vera_type_name`) answers nothing for the index expression and falls to the phantom-var `Bool` default, while the WASM call-rewrite consultor (`InferenceMixin._infer_vera_type`), which DOES have an `IndexExpr` arm, names `idg$Int` at the call — the two consultors disagreeing, which is the clone-name agreement contract's (#772) failure mode. Loud, never a wrong answer. Measured during #1286's review sweep and unchanged by its fix, which closed the same consultor-parallelism gap for `Block`, `MatchExpr`, `IfExpr` and `HandleExpr` — each an exact one-line mirror of its twin — and deliberately left this one: the rewrite's arm delegates to `_infer_index_element_type`, which resolves chained indexing, type aliases and representation-transparent `Future` payloads against codegen tables the monomorphizer does not carry, so a partial mirror would answer differently from the rewrite for those cases, replacing a shape where both consultors say "unknown" with one where they disagree. Fix direction: lift the element-type derivation into one helper both consultors call, rather than a second partial copy that can drift from the first — the shape #1286 argues for throughout. | [#1327](https://github.com/aallan/vera/issues/1327) | | A match binding's GC shadow-stack push is never popped, so the shadow stack grows with recursion depth until it traps. Both `_translate_match`'s scrutinee rooting and `_setup_match_arm_env`'s binding rooting push onto `$gc_sp` and leave it advanced for the rest of the body; a function restores `$gc_sp` only on the way out, so a recursive function whose body matches pushes once per live frame and eventually crosses `$gc_stack_limit`, whose overflow check is a bare `unreachable`. Bisected on a `decreases`-guarded recursion carrying one match: an ADT (i32) scrutinee survives depth 2046 and traps at **2047**, **identically before and after [#1305](https://github.com/aallan/vera/issues/1305)**, which is what makes this pre-existing rather than new — the i32 rooting has behaved this way since #705/#707. What #1305 changed is reach: a `String` / `Array` scrutinee now takes this path too, and because the pair form roots twice per frame (scrutinee pointer and binder pointer) it survives only 1364 and traps at **1365** — two thirds of the ADT depth, which is what the slot arithmetic predicts: an ADT frame roots the parameter and the binder, a pair frame roots the parameter, the scrutinee pointer and the binder pointer, so three slots per frame against two and 2047 x 2/3 = 1364.7. Loud in every case — `unreachable`, never a wrong value — but the trap names no source construct and the depth at which it arrives is an artefact of how many pushes a body happens to make. Fix direction: pop what a match arm pushes at the arm's end (or scope the rooting to the arm body), so the depth a program can recurse to stops depending on how many matches its body contains. | [#1322](https://github.com/aallan/vera/issues/1322) | | Codegen's built-in-container branches are tested before `_adt_layouts`, so a user `data` declaration named after a container is classified by the container's representation instead of its own. `_type_expr_to_wasm_type` answers `Array` with `i32_pair` before it ever consults the registered ADT layouts, so `private data Array { MkArr(Int) }` — a perfectly ordinary declaration spec §8.4.1 permits — has its parameter classified as a two-word pair rather than the one-word heap pointer its constructor actually builds. `Map`, `Set`, `Decimal`, `Tuple` and `Future` are inert instances of the same ordering (their branch answers `i32`, which is what the ADT branch would have answered anyway) and compile and run correctly; `Array` is the one name whose two answers differ, and it fails on both sides of [#1309](https://github.com/aallan/vera/issues/1309) — as an unassemblable `i32_pair` local before it, and as an `[E602]` skip after. That E602 currently misattributes the cause, since the message it inherits from the [#1305](https://github.com/aallan/vera/issues/1305) pair guard describes the scrutinee's representation rather than the declaration that produced it; the wording no longer names `String` / `Array` as the source types, but a reader still has to know that a user `data Array` is why a pair representation was claimed at all. Sibling of #1309 (which reordered the alias branch ahead of these same container branches) and of [#1316](https://github.com/aallan/vera/issues/1316) (which is the environment those branches are consulted in) — one function, three orthogonal questions. Fix direction: consult `_adt_layouts` before the built-in container branches, matching `_resolve_named`, where a DECLARED ADT precedes the built-in absorption. | [#1321](https://github.com/aallan/vera/issues/1321) | diff --git a/TESTING.md b/TESTING.md index 89f2f86a..ea569f8d 100644 --- a/TESTING.md +++ b/TESTING.md @@ -777,7 +777,7 @@ pytest "tests/test_stress.py::test_array_map_over_10k_int_array[eager_gc]" -m st **Failure reporting (cron only)**: when the nightly cron fails, the workflow opens an issue titled "Nightly stress regression on main (tracking)" with the `stress-regression` label, including the commit SHA and the run URL. If an open issue with that label already exists, the new failure posts a comment on it instead of filing a duplicate — so the issue persists across days of failures until a maintainer manually closes it. The `stress-regression` label is auto-created on first failure. This converts cron failures from "visible only to whoever opens the Actions tab" to "visible in the issue feed where Vera work is already triaged." Implementation uses `actions/github-script@v9` with `issues: write` job-scoped permission. -**Budget**: the workflow's suite — `tests/test_stress.py` per the invocation above; the marker's other 10 instances in `test_codegen_gc_reclamation.py` ride the main per-PR suite — completes in well under the 5-minute target: measured at **0.66s in-process** on a developer laptop on 2026-05-13 for its 16 test instances (9 logical × eager-GC lane on 7 of them). CI cold-start adds workflow setup time on top. Iteration counts are tuned to the smallest scale where each bug class has historically manifested with ~2-3x safety margin, NOT maximised — the goal is reliable detection of the bug class, not benchmarking. If this measured figure drifts more than ~2x in either direction, treat it as a signal: either iteration counts have grown without rationale (revisit per the "Adding a stress test" rule 2) or a runtime perf regression has landed. +**Budget**: the workflow's suite — `tests/test_stress.py` per the invocation above; the marker's other 10 instances in `test_codegen_gc_reclamation.py` currently run only under an explicit `pytest -m stress` invocation, since the per-PR suite deselects the marker and this workflow is file-scoped ([#1328](https://github.com/aallan/vera/issues/1328)) — completes in well under the 5-minute target: measured at **0.66s in-process** on a developer laptop on 2026-05-13 for its 16 test instances (9 logical × eager-GC lane on 7 of them). CI cold-start adds workflow setup time on top. Iteration counts are tuned to the smallest scale where each bug class has historically manifested with ~2-3x safety margin, NOT maximised — the goal is reliable detection of the bug class, not benchmarking. If this measured figure drifts more than ~2x in either direction, treat it as a signal: either iteration counts have grown without rationale (revisit per the "Adding a stress test" rule 2) or a runtime perf regression has landed. **Assertion shape**: each test asserts on a SPECIFIC observable (e.g. `array_fold` returning the closed-form sum `4999950000`, `IO.print` producing exactly 10000 `x` characters), not just "completed without crashing". This catches a future regression where the loop silently short-circuits or skips iterations. From c8c982f5fb7a656d19c5317a605db6705e1fd1bc Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Fri, 14 Aug 2026 17:39:04 +0100 Subject: [PATCH 57/69] Make the release body fit, and widen the gates to the drift they missed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v0.1.12 burndown's release and gate tooling: two defects, five riders. `release.yml`'s `Tag and create GitHub Release` step 422'd on v0.1.10, whose CHANGELOG section extracts to 147,918 characters against GitHub's 125,000 limit, and it failed after PyPI had accepted the immutable archives and after the tag was cut — the one point in the pipeline where a step must not fail. `scripts/release.py notes` is now total. Within budget it publishes the section verbatim; past it, it regenerates the shape the v0.1.10 release was completed by hand with — the `###` subsection headers, one condensed line per bullet carrying its lead-in and its last issue or pull-request reference, and a link to the canonical section in the CHANGELOG at the tag. Run against v0.1.10's real section the generated index reproduces the released body's 73 index lines byte for byte. In the pathological case where even the index overflows it is truncated and says so, so the builder cannot be the thing that fails. `scripts/check_grammar_alignment.py` held rule-name headers together and was blind to terminals, rule references and production bodies. It now audits terminals declared against terminals referenced, within each file and in both directions; compares the pattern of every regex-bodied terminal across the two files, after a semantics-preserving normalisation of Lark's delimiter and quote escapes; and compares the rules and terminals each of the 80 shared productions refers to, with Lark's quoted literals mapped through the chapter's own terminal table rather than a hand-written one. Two notational differences are folded rather than reported: a rule's reference to itself, since Lark spells repetition with left recursion where the chapter uses a Kleene star, and a waived spec-only production, which the existing ALLOWLIST already pins to the Lark rule that inlines it. The body comparison needs no waivers of its own. The extended gate went red on four live Chapter 10 defects, two of them beyond the two the issue named. Typed holes absent from `primary_expr` and `BLOCK_COMMENT`'s non-nesting regex were known. The new body comparison found the other two: `slot_ref` and `result_ref` admitted an arbitrary `type_expr` where the parser accepts only `UPPER_IDENT type_args?`, so the published grammar called a refinement-typed slot reference legal when it is a syntax error; and `effect_list` carried a second alternative ambiguous with the one beside it, `effect_ref` already admitting a bare `UPPER_IDENT`. All four are fixed in the chapter. The five riders. `KNOWN_ISSUES.md`'s Bugs table is gated one row per open `bug` issue — structurally always, and against the tracker behind `--check-bug-issues`, because a pre-commit hook must not depend on a network call and mid-cycle the two legitimately disagree. TESTING.md's dual-target row is gated against the conformance manifest and a live run of the differential, with two arithmetic checks the individual figures cannot make. `check_examples_run.py` derives which examples need an output sentinel from what each one declares, validated against the live effect registry, instead of naming three. The burndown's ad-hoc corpus differential becomes `scripts/check_corpus_differential.py`, documented as a CI-optional instrument and deliberately not a hook. And README's project-status line has every count gated rather than only its test count — four of the five patterns that used to sit there matched no README text at all, and returned silently rather than saying so. Closes #1288 Closes #1290 Co-Authored-By: Claude --- .github/workflows/release.yml | 1 + CHANGELOG.md | 8 + CLAUDE.md | 2 + FAQ.md | 2 +- KNOWN_ISSUES.md | 2 - README.md | 2 +- RELEASING.md | 9 +- ROADMAP.md | 2 +- TESTING.md | 19 +- docs/llms-full.txt | 2 +- scripts/check_corpus_differential.py | 728 ++++++++++++++++++++++++ scripts/check_doc_counts.py | 388 +++++++++++-- scripts/check_examples_run.py | 260 ++++++++- scripts/check_grammar_alignment.py | 478 +++++++++++++++- scripts/release.py | 159 +++++- spec/10-grammar.md | 12 +- tests/test_check_corpus_differential.py | 498 ++++++++++++++++ tests/test_check_doc_counts.py | 278 +++++++++ tests/test_check_examples_run.py | 361 +++++++++++- tests/test_grammar_alignment.py | 224 ++++++++ tests/test_release.py | 193 ++++++- vera/README.md | 2 +- 22 files changed, 3532 insertions(+), 98 deletions(-) create mode 100644 scripts/check_corpus_differential.py create mode 100644 tests/test_check_corpus_differential.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c0b89eab..3b67ad4a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -162,6 +162,7 @@ jobs: run: | python scripts/release.py notes \ --version "$VERSION" \ + --repo "$GITHUB_REPOSITORY" \ --output release/RELEASE_NOTES.md python scripts/release.py manifest \ --dist-dir dist \ diff --git a/CHANGELOG.md b/CHANGELOG.md index e631af3d..b8e1b866 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - **The examples are now RUN in CI, not only checked, verified and compiled** (`scripts/check_examples_run.py`). `check_examples.py` type-checks and verifies all 42, and `check_e602_clean.py` compiles all 42 as a side effect of policing silent translator skips — but nothing executed them as a set, and an audit of every referencing test found **seventeen examples that no test ran at all**: `array_utilities`, `async_http_fanout`, `collections`, `database`, `fizzbuzz`, `html`, `http`, `inference`, `io_operations`, `json`, `life`, `maximum_syntax`, `modules`, `nested_closures`, `read_char`, `scoreboard` and `string_utilities`, plus `file_io`, which ran only under the browser runtime, where the file IO it demonstrates is a deliberate `Err` stub. Between them they demonstrate `Map`/`Set`, the `` effect, JSON and HTML parsing, module imports and the whole string-utility family, so a runtime regression in any of it could reach a release with every gate green. The gate now runs 34 of the 42 under the native runtime and asserts a trap-free exit; the other 8 carry a documented skip property (`network`, `api-key`, `stdin`, `non-scalar-entry`, `long-running`) which the report prints with its reason on every run. Output pinning deliberately stays in the dedicated tests that already do it, so the gate does not go red on a cosmetic edit to an example. The load-bearing part is not the runs but the **coverage rule**: the script enumerates `examples/*.vera` from disk and requires every name to be in exactly one of its two tables, so an unclassified example is an error and adding one forces the author to decide whether the harness can drive it — and a table key whose file is gone is an error too, so a suppression cannot outlive its example and mask a later program of the same name. The classification is cross-checked against a new execution-coverage table in `TESTING.md` on the `check_doc_counts.py` model, the codebase being the oracle and the documentation having to match it, so the execution model stops living in maintainers' heads. Trap-freedom is asserted on two signals, the discipline `check_examples.py` already applies: the exit code, and an output signal. Either alone accepts a measured failure. Every spec names its entry point rather than relying on `vera run`'s first-export fallback — with `main` privatised, `array_utilities.vera` ran a different function and the gate passed; it now exits 1 on the name. And the three examples that reach outside the process (`sqlitedb.vera` for its committed fixture, `database.vera` for an in-memory database, `file_io.vera` for the filesystem) answer a failure by printing a message and completing normally, so each pins a substring only its success path prints — deleting `examples/sqlitedb.sqlite` left the gate green on the graceful in-memory arm, and now fails on the sentinel. Runs are hermetic: an ambient `VERA_DB_URL` or inference-provider key is stripped from the environment, so a gate run cannot be pointed at a real database or turned into a billed API request, and each example gets a scratch working directory so `file_io.vera` stops dropping `hello.txt` beside the sources. `TESTING.md`'s round-trip section is corrected with them — it claimed all 42 examples were tested through "every pipeline stage ... WASM compilation, and execution", where the directory-globbing parametrised tests in fact stop at verification and canonical form. +- **The grammar-alignment gate now compares terminals and production bodies, not only rule names** ([#1290](https://github.com/aallan/vera/issues/1290)). `scripts/check_grammar_alignment.py` held rule-name headers together and was blind to three drift classes, each demonstrated green on a live file during #1279's review: a fabricated terminal added to spec 10.2 (the header pattern requires a lowercase lead, so no terminal was seen at all), a rule reference restored to a right-hand side, and a production body edited on one side only — the class most grammar edits actually fall into. Three checks close them. A **terminal audit in both directions, within each file**: a terminal declared and never referenced, or referenced and never declared, is now an error — the shapes `SOME`/`NONE`/`OK`/`ERR`/`COLON` and `DOUBLE_COLON` had between them, found by hand and fixed in #1279 with the gate itself unable to see either. A **cross-file terminal-pattern check**: every terminal the chapter publishes as a bare regex must have that pattern in `vera/grammar.lark`, as a named terminal or an `%ignore`, after a semantics-preserving normalisation of Lark's `\\/` and `\\"` escapes — which is the whole of the difference between how the two files spell `STRING_LIT` and `ANNOTATION_COMMENT`, and which `BLOCK_COMMENT` failed. And a **production-body comparison** over the 80 rules both files declare, of the rules and the terminals each right-hand side refers to, with Lark's quoted literals mapped through the chapter's own terminal table rather than a hand-written one. Two notational differences are folded rather than reported: a rule's reference to itself, since Lark spells repetition with left recursion where the chapter uses a Kleene star, and a waived spec-only production, which the existing `ALLOWLIST` already pins to the Lark rule that inlines it. The body comparison needs no waivers of its own, and the six-entry rule-name allowlist is unchanged. +- **`KNOWN_ISSUES.md`'s Bugs table is gated one row per open `bug` issue.** The structural half is pure text and always on: each row's Issue column must hold exactly one `[#N](…/issues/N)` link whose number matches its URL, no two rows may claim one issue, and an empty section must be written `No known bugs.` rather than left as a bare table. The parity half needs the tracker, and a pre-commit hook must not depend on a network call, so it is opt-in through `scripts/check_doc_counts.py --check-bug-issues` for the release PR — mid-burndown the two legitimately disagree, a bug filed against an open PR's branch having an issue before it has a row. +- **TESTING.md's dual-target conformance row is gated against the manifest and a live run.** The row states a run-level total, a tested/skipped split and three category counts, and claims the excluded set "stays accurate as programs are added" — a claim nothing measured. The total now comes from the conformance manifest and the rest from a three-second `-rs` run of the differential itself, with two arithmetic checks the individual figures cannot make: tested plus skipped must be the run-level total, and the three categories must be the skip total. A skip whose reason matches none of the three documented properties fails rather than being folded into one of them. +- **`check_examples_run.py` derives which examples need an output sentinel instead of naming them.** The rule was a hard-coded triple — `database`, `file_io`, `sqlitedb` — so a fourth example reaching outside the process could be added with nothing but an exit code asserted, exactly the gap the sentinel exists to close. The set now comes from each program's own declarations: a resource effect in a function's effect row, or a call to a resource operation, read off the parsed AST rather than the source text so a header comment mentioning `` is prose. Both halves are needed, and the measurement said so: `FileIO` and `Time` are not effects in this language — file and clock operations live under `IO` — so `file_io.vera` declares exactly the bare `` that `hello_world.vera` does, and only the operation it calls separates them. What stays hand-written is a short list of registry *names*, and those are validated against the live effect registry, so a renamed or deleted effect or operation fails loudly rather than silently matching no example. The derived set must equal the specs carrying a sentinel in both directions, so a sentinel on an example with no resource signal is an error too. +- **`scripts/check_corpus_differential.py`** promotes the burndown's ad-hoc corpus differential to a first-class instrument: it compiles every corpus program at two revisions and reports the movers, including the programs that compile on one side only. It is deliberately not a pre-commit hook — it compiles the whole corpus twice — and is documented as a CI-optional burndown instrument. ### Fixed @@ -32,6 +37,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **The Vera-level type namers join over a conditional instead of reading one branch** ([#1286](https://github.com/aallan/vera/issues/1286)). [#1276](https://github.com/aallan/vera/issues/1276) fixed the WAT result-type deciders to take the first branch that yields a type; their Vera-level siblings kept the one-branch read — `InferenceMixin._infer_vera_type` (the WASM call-rewrite consultor) read `then_branch` only and `arms[0]` only, and `Monomorphizer._infer_vera_type_name` (the instantiation-discovery consultor) read `then_branch` only and had no `MatchExpr` arm at all. A branch whose every path `throw`s names no type, so reading only that branch answered "unknown" for the whole expression, and the issue's latency estimate was wrong in the program's favour: the shape is constructible, and it is loud in two different ways from check-green source. As an **array-literal element** (`[if false then { throw(true) } else { 42 }, 7]`, and the `match` and `String` spellings of the same position) the unknown element type raised `CodegenSkip`, so a declared `public fn main` — `vera check`-green and `vera verify`-green at 2 Tier 1 — was absent from the compiled exports behind an `[E602]` note. As a **generic argument** (`idg(if false then { throw(true) } else { 42 })`, verify-green at 4 Tier 1) the type variable bound nothing and the clone fell to the phantom-var default: the module carried `idg$Bool`, an i32 clone, reached with an i64 argument, and failed to load with `Invalid input WebAssembly code at offset 73: type mismatch: expected i32, found i64`. The same reading through a constructor **field** mis-instantiated the unboxing clone the other way round (`expected i64, found i32`). The repair lands on both consultors together because the `match` case was broken in both directions: with every arm completing and nothing diverging, the rewrite named `idg$Int` from arm 0 while discovery, having no arm for `MatchExpr`, named the phantom default, and the caller was dropped on a dangling target — the clone-name agreement contract ([#772](https://github.com/aallan/vera/issues/772)) makes the pair, not either function, the unit of repair. All 291 pre-existing corpus programs (`examples/` plus `tests/conformance/`, recursive) emit byte-identical WAT, since the join only changes an answer that was previously unknown; `ch02_generic_arg_branch_join` promotes the witness into the conformance suite at level `run`. The review round closed the same divergence in two further shapes, both of them the one gap — the discovery consultor must stay structurally parallel to the rewrite one, arm for arm. It had no `Block` arm, and the transformer leaves a braced match-arm body AS a `Block`, so `idg(match … { Some(@Int) -> { let @Int = @Int.0 + 1; @Int.0 }, None -> throw(true) })` named nothing on the discovery side and `idg$Int` on the rewrite side: a dangling target that dropped `main` from check-green source. A braced `if` branch whose tail is itself braced does the same, and so does a `handle` in argument position, which likewise had no arm. An `IndexExpr` argument is measured to dangle identically and is deliberately left for its own change ([#1327](https://github.com/aallan/vera/issues/1327)): the rewrite's arm resolves chained indexing, aliases and `Future` payloads against codegen tables the monomorphizer does not have, so a partial mirror would replace a shape where both consultors answer "unknown" with one where they disagree. +- **A GitHub Release body that would exceed the 125,000-character limit is condensed instead of failing** ([#1288](https://github.com/aallan/vera/issues/1288)). `release.yml`'s `Tag and create GitHub Release` step 422'd on v0.1.10, whose CHANGELOG section extracts to 147,918 characters, and it failed **after** PyPI had accepted the immutable archives and **after** the tag was cut — the one point in the pipeline where a step must not fail. `scripts/release.py notes` is now total: within budget it publishes the section verbatim, and past it, it regenerates the shape the v0.1.10 release was completed by hand with — the section's `###` subsection headers, one condensed line per bullet carrying its lead-in and its last issue or pull-request reference, and a link to the canonical section in the CHANGELOG at the tag. Run against v0.1.10's section the generated index reproduces the released body's 73 index lines byte for byte. In the pathological case where even the index overflows it is truncated and says so, so the builder cannot be the thing that fails. +- **Four production-level divergences between spec Chapter 10 and the parser are closed** ([#1290](https://github.com/aallan/vera/issues/1290)). Typed holes have been in `grammar.lark` since 2026-03-30 and appeared nowhere in the chapter: 10.2 now declares `HOLE` and `primary_expr` carries the alternative, so the chapter's expression grammar is the parser's. 10.2's `BLOCK_COMMENT` published a non-nesting regex, contradicting 1.3 ("They nest") and the implementation, which counts depth in `vera/lexical.py` because a regular expression cannot; it is now a nesting production with that fact recorded beside it. The other two the new body comparison found: `slot_ref` and `result_ref` admitted an arbitrary `type_expr`, where the parser accepts only `UPPER_IDENT type_args?` — a refinement-typed slot reference is a syntax error, and the published grammar said it was legal; and `effect_list` carried a second alternative ambiguous with the one beside it, `effect_ref` already admitting a bare `UPPER_IDENT`, the same redundancy #1279 removed from `statement`. +- **README's project-status line has every count gated, not just its test count.** The `check_readme` helper returned silently when a pattern matched nothing, and four of its five patterns matched no README text at all — so the conformance count sitting beside the gated test count drifted through two rebases unseen. The line's four countable figures — tests, conformance programs, examples and spec chapters — are now read from that line alone, and a figure that has gone missing is an error rather than a skip. - **A user-defined `fn get` / `fn put` is no longer hijacked by an enclosing handler** ([#1284](https://github.com/aallan/vera/issues/1284)). Three sites answered "does this `get` mean the user's declaration or the effect operation?" independently. The checker answers user-fn-first — `_check_call_with_args` looks a bare name up as a function before it looks it up as an operation, so a declaration named `get` owns every bare `get(...)` in its scope, which an arity or argument-type error at such a call site proves by reporting the *user's* signature (E201/E202). Codegen answered twice more: the declared-effect row in `vera/codegen/functions.py` withheld the intrinsic when `_fn_sigs` already owned the name, and the handler expression in `vera/wasm/calls_handlers.py` installed `get`/`put` unconditionally. From `vera check`-green source that produced, depending on the nesting shape, a **silently wrong value** (`nat_to_int(get(3))` under `handle[State](@Int = 5)` returned the cell's 5 for the function's 4, and the argument was not even emitted), a **module WASM validation rejects** (a `@Bool`-returning user `get` took `state_get_Int`'s i64 into an `i32` position; different-family nesting took the *enclosing* cell's getter at the wrong width), or a **spurious `[E602]`** in which the #1233 unaddressable-cell gate refused `main` outright, naming "a bare or qualified State operation `get`" the program never contained. The repair is one predicate, `vera.slots.bare_call_denotes_user_fn`, stating the checker's rule once and consumed by the bare-call dispatch in `vera/wasm/calls.py` (which now gates the clause-inline registry, the host-cell intrinsics and the addressability gate together), by the three bare-`FnCall` result-type inference sites, and by the monomorphizer's discovery walk — each passing its own name table, so the sites cannot answer differently about the table they share. Gating the *dispatch* rather than the *registries* is what makes it correct rather than merely consistent: the registries record which cell an op name reaches, which is true whatever the program's declarations are called, and withholding an entry answered both questions with one table. That is why the gate-only fix measured during PR #1283's review turned the loud skip into a differently-broken module, and why it also cost the qualified spelling its cell — `State.put(5)` in a function that also declares `fn put` compiled to `call $vera.put` and failed to link, which now lowers to the intrinsic the checker always meant. Discovery's `MonoContext.fn_names` moves to the same lookup-time question, so a `get(())` fixing a generic's type variable under a handler names the clone the rewrite emits. All 256 conformance and example programs emit byte-identical WAT. **The `W002` async-commutativity warning was the same defect in the checker's own file** and is corrected with them: `_collect_expr_effects` asked `lookup_effect_op` before the scoped function lookup — the last op-first consumer — so a user function named after an operation contributed the *operation's* parent effect to the commutativity analysis instead of its own declared row, wrong in both directions. A **pure** `fn get`, in a program containing no `State` at all, drew `async argument performs State effects`; a `fn get` that performs `IO`, under a row naming `Http` first, drew **no warning**, because the walk bound the name to `Http.get`, which is inside the commutative whitelist, and silently withheld the eager-evaluation warning the program is owed. Both are pinned with rename controls — the byte-identical program with the helper called `gett` / `fetch` was correct throughout — beside a control that an unshadowed bare `get(())` under a `State` row still warns, so the fix cannot degenerate into never reporting `State`. The walk's comment claiming it resolves "like the call checker above" is now true. One caveat the predicate did not close on its own: codegen's name table was not scope-accurate, so a name the call site cannot see still answered "user-owned" there — a property of the table rather than of the rule, closed by [#1299](https://github.com/aallan/vera/issues/1299) below. - **A bare call is lowered against the names its call site can see** ([#1299](https://github.com/aallan/vera/issues/1299)). The [#1284](https://github.com/aallan/vera/issues/1284) ownership predicate is one rule read over two tables, and only one of them was a scope. The checker's is a lexical walk; codegen passed `set(_fn_sigs.keys())` — a flat mirror of every symbol the whole compilation absorbed — so a bare `get(())` the checker had resolved to a `State` operation was lowered as a call to a declaration the body cannot name. Four source shapes reach it, all `vera check`-green and all one defect: an imported module's **private** `fn get` (invisible, but still compiled in because the module's own bodies call it), a **public** one a selective import excludes, a `where` helper of a **`forall` parent** (which keeps a bare `_fn_sigs` key beside its clone-qualified one where a non-generic parent's helper does not), and the ability operation `show`, which `E151` does not reserve and which reaches the same table through the *intrinsic* gate rather than the operation one. How it lands is a property of the widths, not of the route: where the invisible declaration and the cell share a WAT type the module loads and returns the wrong value (7007 where the cell holds 42007), where they differ it fails to load (`type mismatch: expected i64, found i32`), and the generic-`where` route is always loud — the bare key exists in the signature table while no bare *symbol* is emitted, so the call dies at WAT assembly on `unknown func: failed to find name $get` with no E-code. The repair splits the two questions the one set was answering. `_known_fns` keeps the flat registry for `_translate_call`'s guard rail, which asks whether a *resolved* target — already mono-mangled, already `mod$…` rerouted — has an implementation, and is flat by nature. A new `_scoped_fns` carries the names visible in the compiling declaration's **lexical** scope, and that is what the ownership predicate reads: its namespace's own declarations plus the public, in-filter names of the imports *that namespace* makes (spec §8.6.4 — imports are never inherited, so a transitively-reached module contributes nothing to the entry program), the prelude, and the `where` helpers of every enclosing function. Module scope alone would not have closed the third route: a generic's helper *is* in the module and still is not in a sibling's scope. The narrowing is a strict subset of the registry by construction — every `$`-bearing key is admitted unconditionally, since `$` cannot occur in a Vera identifier and a mangled name is never what a bare source call spells — so it can only withdraw a name the flat table wrongly claimed. diff --git a/CLAUDE.md b/CLAUDE.md index 442b878d..d218d88b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,6 +80,8 @@ python scripts/build_site.py # Regenerate AI-readable site assets (llms python scripts/check_site_assets.py # Verify site assets are up-to-date + docs/index.html ↔ docs/index.md state coherent facts (#1154) python scripts/check_version_sync.py # Verify version consistency python scripts/check_doc_counts.py # Verify documentation counts match codebase +python scripts/check_doc_counts.py --check-bug-issues # Also check KNOWN_ISSUES' Bugs table against the open `bug` issues (GitHub API; release-PR time, not pre-commit) +python scripts/check_corpus_differential.py --base-ref origin/main # Compile the corpus at two revisions; report programs whose WAT moved (burndown instrument, not a hook) python scripts/check_licenses.py # Verify all package licenses are MIT-compatible python scripts/check_wheel_availability.py # Verify every runtime dep has wheels for all supported platforms (README §Supported platforms) python scripts/check_limitations_sync.py # Verify limitation tables are in sync diff --git a/FAQ.md b/FAQ.md index 716dcd6d..aca7fb1d 100644 --- a/FAQ.md +++ b/FAQ.md @@ -279,7 +279,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 11,786 tests, including a 244-program conformance suite +- 11,907 tests, including a 244-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 7d66d41f..3d7b1f37 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -21,8 +21,6 @@ Defects in shipped compiler, runtime, or tooling behaviour — this table matche | The checker resolves a bare call to a SIBLING function's `where` helper. Spec §5 makes a helper local to its parent, and the checker's own `_lookup_function_scoped` implements that — it walks the enclosing frame stack and reads each frame's direct helpers — but it then falls back to `env.lookup_function`, and `vera/registration.py` has recursed every helper into that flat `TypeEnv`. So a top-level `other()` calling `helperx(7)`, where `helperx` is a helper of an unrelated `holder`, is **check-green and verify-green** and then refused by codegen (`Function 'helperx' is not defined in this module and was not found in any imported module`) — the helper is emitted as `holder$where$helperx`, so the bare call has no target. Loud, never a wrong answer. The op-name variant is the one that matters for the #1284 ownership predicate: with the helper named `get` and the sibling reading a `State` cell by bare `get(())`, the checker binds the HELPER and reports `[E202] Argument 0 of 'get' has type Unit, expected Int` where spec §7.4 resolves the operation — so the checker rejects a program codegen compiles correctly, the two tables disagreeing in the CHECKER's direction for the first time. Codegen's `_scoped_fns` (#1299) implements the spec rule; the fix here is a checker change, whose new rejections need their own blast-radius pass. | [#1307](https://github.com/aallan/vera/issues/1307) | | `md_parse` diverges between the native and browser runtimes on **211 of 1,471** adversarial inputs (14.3%) and **329 of 4,858** blank-line-separated sections of the project's own documentation (6.8%), measured at [PR #1303](https://github.com/aallan/vera/pull/1303) by comparing the two ADTs directly rather than their renders. The second denominator is the repository's own Markdown, so it moves whenever a document gains or loses a section; 11 of those 4,858 also differ in the rendered output. Nine classes, each with a one-line repro. The largest by a wide margin — 173 of the 211 — is *plain-text run grouping*: the browser emits one `MdText` per scan segment where the reference coalesces adjacent runs, so `**unclosed` is `[MdEmph([]), MdText("unclosed")]` natively and `[MdText("*"), MdText("*unclosed")]` in the browser. That class is invisible to `md_render` (the runs concatenate to the same text) but not to a Vera program that matches on the ADT, which is what makes it a §12.9.3 violation rather than a cosmetic one. The rest are render-visible: emphasis/strong scanning (`***both***` renders `**both****` natively, `***both***` in the browser); list-continuation indent width, where the reference strips exactly two (or three) characters and the browser strips all leading whitespace (`- a\n b` → `- a b` vs `- a b`); a `+` bullet, unrecognised in the browser; an `n)` ordered marker, likewise; a loose list, one list natively and two in the browser (`- a\n\n- b`); nesting past two levels, flattened in the browser (`- a\n - b\n - c`); a thematic break with internal spaces (`* * *`); and a table without a separator row (`\| a \| b \|\n- li`). Neither implementation is the specification — §9.7.3 pins the ADT, not the grammar that produces it — so closing this means choosing a parse for each class and stating it, then mirroring. Successor to [#1294](https://github.com/aallan/vera/issues/1294), which closed the `md_render` half; the parity suite pins the shapes they do agree on, so a regression on one of those goes red. | [#1301](https://github.com/aallan/vera/issues/1301) | | A postcondition may name a `State` the function's effect row never declares, and `vera check` accepts it: `ensures(new(State) == false)` under `effects(>)` reports OK, then `vera compile` fails with **E699** — the internal-compiler-error diagnostic whose own text says the type checker should have rejected the input, which is exactly the situation. Both forms land there for the same reason (no cell of that family exists, so `old()` finds no snapshot local and, since [#1285](https://github.com/aallan/vera/issues/1285), `new()` finds no getter). Loud and never a wrong answer, so this is diagnostic quality rather than soundness — but it is a check-green program that cannot compile, reported against the compiler instead of against the program, with a bug-report request the user should not act on. Before #1285 the `new()` side was worse than loud: the name-keyed lookup found the row's other getter and silently read the wrong cell. Fix direction: validate an `OldExpr`/`NewExpr`'s effect reference against the declared row where the checker already validates the rest of the clause, one rule for both forms; `test_a_family_the_row_does_not_declare_is_loud_on_both_sides` pins today's E699 and is the test to flip. | [#1298](https://github.com/aallan/vera/issues/1298) | -| Spec Chapter 10 is a second, hand-maintained copy of `vera/grammar.lark`, and the rule-name alignment gate `scripts/check_grammar_alignment.py` compares only rule-name headers — three classes of drift pass it unseen. **Terminals, both directions**: the header pattern requires a lowercase lead, so a fabricated terminal added to §10.2 leaves the gate green, and no declared-versus-referenced audit exists in either direction. **Rule references**: restoring a removed ambiguity to a production's right-hand side is invisible, because only headers are compared. **Production bodies**: the class most grammar edits actually fall into. Two instances are live in the chapter on `main` today, both body-level and both this issue's to close: typed holes (`"?" -> hole_expr`, in `grammar.lark` since 2026-03-30) appear nowhere in `primary_expr`, and §10.2's `BLOCK_COMMENT: /\{-[\s\S]*?-\}/` is non-nesting, contradicting both §1.3 ("They nest") and the implementation, which parses `{- a {- b -} c -}` clean. Never a wrong answer from a program — the defect is that the published grammar misdescribes the one the parser has. Fix direction: extend the gate side-aware (terminal audit both ways, reference-set comparison), or fold Chapter 10 toward the DESIGN Grammar row's actual promise of a single-sourced shared grammar rather than a hand-maintained copy held honest by ever-wider cross-checks; the two live instances are fixable independently of which direction wins. | [#1290](https://github.com/aallan/vera/issues/1290) | -| `.github/workflows/release.yml`'s `Tag and create GitHub Release` step fails with `HTTP 422: Validation Failed — body is too long (maximum is 125000 characters)` when the CHANGELOG section `scripts/release.py notes` extracts into `RELEASE_NOTES.md` exceeds GitHub's release-body limit. It fired on v0.1.10, whose notes extract to roughly 148,700 bytes — the #1213 burndown's 44-issue section, some 23,000 characters past the limit — and it fired at the worst point in the pipeline: **after** PyPI had accepted the immutable archives and **after** the tag was created, leaving the release half-cut with no repeatable path back. The v0.1.10 GitHub Release was completed by hand, mirroring the step exactly: the run's artifacts downloaded and hash-verified three ways, then `gh release create --verify-tag --latest` with the wheel, sdist and SHA256SUMS, and a generated body — the section's bold bullet lead-ins as a headline index plus a link to the canonical section at the tag. Rare, since it needs a release this large, but rarity is not the mitigating factor here; the landing point is. Fix direction: make the step total — before `gh release create`, regenerate oversized notes into that index form so the release always carries a body that fits, with the full notes staying in `CHANGELOG.md`, which is already the release notes of record. | [#1288](https://github.com/aallan/vera/issues/1288) | | `ch05_closure_nat_return` (a run-level conformance program in the pre-commit + CI gate) trapped **once** in a full `check_conformance.py` run (`unreachable` in `main` — the sentinel `assert` or a GC shadow-stack guard) and has not reproduced in ~960 attempts across isolated, parallel, eager-GC, and hash-seed-swept executions; the emitted WAT is deterministic and correct. Suspected rare runtime/GC/wasmtime interaction, tracked so a future intermittent CI red resolves here instead of starting fresh. | [#996](https://github.com/aallan/vera/issues/996) | ## Limitations diff --git a/README.md b/README.md index b5f7593e..ef355bee 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ cp /path/to/vera/SKILL.md ~/.claude/skills/vera-language/SKILL.md ## Project status -Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 11,786 tests, 95% Python code coverage, 244 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. +Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 11,907 tests, 95% Python code coverage, 244 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. The reference compiler — parser, AST, type checker, contract verifier (Z3), WASM code generator, module system, browser runtime, and runtime contract insertion — is working. The language specification is in draft across [14 chapters](spec/). diff --git a/RELEASING.md b/RELEASING.md index ccf3a9e6..645660d7 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -93,7 +93,14 @@ The release-prep PR must: 2. Turn the accumulated `[Unreleased]` notes into a dated `## [X.Y.Z]` section with at least one bullet and update the CHANGELOG compare links. 3. Add the release's one-line HISTORY entry and regenerate site assets. -4. Pass the ordinary protected-branch CI and review process. +4. Reconcile `KNOWN_ISSUES.md`'s Bugs table with the tracker, by running + `python scripts/check_doc_counts.py --check-bug-issues`. The convention + is one row per open `bug`-labelled issue, and the check needs the GitHub + API, so it is opt-in rather than part of the pre-commit hook: mid-cycle + the two legitimately disagree, since a bug filed against an open PR's + branch has an issue before it has a row. At release time they should + agree — that is the point at which the file is the published list. +5. Pass the ordinary protected-branch CI and review process. After merge, `release.yml` detects the version increase on `main`. It then: diff --git a/ROADMAP.md b/ROADMAP.md index 1f54b11c..c67b1c3b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,7 +12,7 @@ Ordering derives from the design principles ([DESIGN.md](DESIGN.md)): verificati ## Where we are -11,786 tests, 244 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. +11,907 tests, 244 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. ## Stage 19 — The verification completeness sprint diff --git a/TESTING.md b/TESTING.md index ea569f8d..94de32c6 100644 --- a/TESTING.md +++ b/TESTING.md @@ -6,7 +6,7 @@ This is the single source of truth for Vera's testing infrastructure, coverage d | Metric | Value | |--------|-------| -| **Tests** | 11,786 across 174 files (~155,000 lines of test code; 11,587 passed + 26 stress, 173 skipped) | +| **Tests** | 11,907 across 175 files (~155,000 lines of test code; 11,708 passed + 26 stress, 173 skipped) | | **Compiler code coverage** | 95% Python, 87% JavaScript (CI minimum: 80%) | | **Conformance programs** | 244 programs across 9 spec chapters, validating every language feature | | **Example programs** | 42, all validated through `vera check` + `vera verify` | @@ -221,10 +221,11 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_build_site.py` | 46 | 698 | Site-asset tooling — `_abs_links` rewriting (relative links, fenced-block immunity incl. inline backticks and tilde fences, http/https/fragment pass-through, Vera effect syntax not mis-parsed), `build_site` `` stability (preserve/refresh keyed on URL-structure change), `check_site_assets` sitemap staleness (missing / date-only-clean / structural-stale), and the #538 leak guard (vera:skip fence annotations stripped from generated `docs/SKILL.md` / `docs/llms-full.txt`, with a non-vacuous precondition that the source carries annotations); plus the #1154 `check_fact_coherence()` suite — index.html↔index.md fact extraction and divergence detection | | `test_builtin_typevar_collision_970.py` | 61 | 811 | #970 a user `forall` type-var name colliding with a built-in generic's internal name (`T`/`E`/`A`/`B`/`K`/`U`/`V`): focused check/verify pins for the compound-argument shapes (`@Array>`, `@Result>`, `@Map>`) plus a collide-vs-control differential battery over every generic-builtin family and contract/where-helper position. Also pins marker-strip (the `#b` namespacing marker must never reach an E205/E202 diagnostic), a registry-consistency pin (every built-in ability-constraint `type_var` stays a member of its `forall_vars`), the dual completeness-gap pinned in both argument orders, a tier-split equality pin, and the #1069 leaked-placeholder message-rendering sweep (a stripped built-in var renders as `?`, not a bare letter, at every reachable actual-type slot: the mismatch sites plus the operator/index/interpolation family, `assert`/`assume`, `if` condition and branches, and the contract/refinement predicates — one parametrized row per converted render slot, with the provably-unreachable sites documented in the class docstring) | | `test_check_changelog_updated.py` | 68 | 712 | `check_changelog_updated.py` unit + end-to-end tests: file classification (incl. file-style exact-match vs directory-style prefix-match), CHANGELOG diff parsing with `[Unreleased]` section tracking, bare-heading rejection, and full-file context (regression test for bullets far below the heading), `Skip-changelog:` trailer detection, temp-repo integration covering substantive/exempt/label/trailer paths, and `GIT_*`-env hermeticity of the temp-repo fixtures (regression for the pre-commit-hook env leak) | -| `test_release.py` | 47 | 566 | Release policy and registry verification (#481): strict project-name and version parsing/comparison, version-bump/TestPyPI/recovery planning, exact confirmation and immutable-tag guards, first-parent version-introduction discovery, package-change recovery refusal, non-empty CHANGELOG extraction, one-wheel/one-sdist SHA-256 manifests, malformed registry-response handling, missing/filename/hash propagation retries, exact filename/hash verification, and CLI dispatch/GitHub-output wiring. An autouse fixture scrubs hook-exported `GIT_*` variables so the tmp-repo git calls (fixture helpers and `release.py`'s own) never resolve to the developer's repository when the suite runs inside a pre-commit hook. | -| `test_check_doc_counts.py` | 54 | 665 | `check_doc_counts.py`'s pure per-document checks: KNOWN_ISSUES refactoring line counts (±10% tolerance band incl. the exact-boundary case, drift detection, empty-file citation, hyphenated paths, missing file/section/rows, the #419 empty-section sentinel + its cannot-mask-a-malformed-table dual), HISTORY version-row format (issue-link limit, ` — ` separator rejection, dateless-row and prose exemption, line-number reporting), the TESTING.md tests breakdown (parts summing to the collected total, a self-consistent-but-stale row, and the reworded-row error), and vera/README.md's Test Suite counts (all four checked independently — mutation-validated by dropping each citation in turn — plus the reworded-paragraph error, a thousands-separator case pinning that every one of the four counts is read comma-tolerantly, and the two section-anchoring cases — a reworded paragraph with decoy counts in a later section, and a renamed heading, both of which must fail loud rather than match across the section boundary). The reworded case is a test in its own right for both new checks: a pattern that matches nothing must be an error, or rewording the sentence silently switches the gate off. Also the release count (README's status line and HISTORY's total against each other and against `git tag`: the matching case, the one-ahead release cut that `release.yml` has not tagged yet, that +1 being the ONLY slack once the version is tagged, the two-behind drift that actually shipped, per-document reporting, and a tagless checkout standing the oracle down without standing down the cross-check), plus the tag reader itself against real repositories built in `tmp_path` — release tags read, `nightly`/`-rc1` not counted as releases, and both no-evidence answers (`None` rather than `[]`, since an empty list would read as zero releases and make every documented count wrong) for a tagless checkout and a directory that is not a repository at all. CONTRIBUTING.md's pre-commit hook count is checked the same way, reworded-sentence case included; and the CI-pipeline lint row against `ci.yml`'s lint job — a matching row, a step present in CI but absent from the row (the drift that shipped), a row entry CI no longer runs, the same set in a different order, a reworded row and a renamed job (both errors, not skips), that only the lint job is read rather than the whole workflow, and the shipped pair both clean and red with one entry dropped | -| `test_grammar_alignment.py` | 44 | 309 | `check_grammar_alignment.py` gate ([#683](https://github.com/aallan/vera/issues/683)): both extractors (Lark headers with the `?`/`!`/`_` markers stripped, template parameters and rule priorities tolerated, and `-> alias` names deliberately not collected; spec headers from ```ebnf fences only), the allowlist arithmetic in all three directions — unwaived drift, a spent entry both files now have, and an entry whose premise broke — one case per waiver proving the fact it rests on is actually checked, the name-deleted-from-both-files case that must not read as agreement, a mutation restoring the spec's old `assert_stmt` name, non-vacuous extraction, and the false positive the issue itself rested on: `qualified_call` and `module_call` are spec headers Lark expresses as aliases, and must never be reported as drift. Three pin the premise checks against ways they used to pass vacuously: an alias surviving only inside a `//` comment must not hold its waiver up, an alias that moved to another rule must fail the waiver naming `fn_call`, and a spent waiver whose premise also broke must yield one instruction rather than two opposite ones | -| `test_check_examples_run.py` | 59 | 885 | `check_examples_run.py`, the harness gate that RUNS the examples. Five separable parts, each in both directions. **The coverage rule** — the shipped tables cover the shipped corpus exactly, and an unclassified example, a stale `RUN_SPECS` or `SKIPS` key whose file is gone, a name in both tables, and a skip citing an undocumented property are each an error; the empty corpus is an error too, since a glob that stops matching would otherwise report success over zero programs. Plus the specs' own well-formedness: every named entry point is `public` in its example, every no-main example pins one (or `vera run` would fall back to an arbitrary first export), and no skip property is unused. **The runner** — a seeded `tmp_path` corpus proves it goes red on a program that type-checks and compiles but traps at run time, green on one that does not, and reports only the broken member of a mixed pair; a fixture whose first export is clean and whose named one traps proves `spec.fn` is actually honoured rather than ignored; a writer program proves each run gets a scratch working directory, so a gate run leaves nothing beside the examples. **The TESTING.md cross-check** — missing row, extra row, rename (reported naming both sides), wrong disposition and wrong skip property are errors, the parse stops at the next heading so a row-shaped line in a later section is not swept in, and both a reworded heading and a heading whose table has vanished fail loud rather than finding nothing to compare. **The output signal** -- the second half of the two-signal discipline `check_examples.py` established: the fallback note that `vera run` prints when it cannot use the named entry point is a failure even at exit 0, an absent `expect` sentinel is a failure even at exit 0, and a spec without one asserts nothing about output; end to end, a privatised `main` and a program that completed down a graceful arm each go red, and the same program passes once its own output is the sentinel, so the check reads the output rather than always failing. The three environment-dependent specs are required to carry a sentinel, and the runner's use of BOTH streams is pinned structurally -- `vera run` writes the note to stderr and nothing there on a clean exit, so no fixture can distinguish reading both streams from reading stdout alone, and a tripwire wired to the wrong stream is no tripwire. Also the hermetic-environment property: an ambient `VERA_DB_URL` or provider key is stripped so a gate run cannot be pointed at a real database or turned into a billed API call, a fixture spec puts its own URL back, and every neutralised name is checked to be one `vera/runtime/` actually reads | +| `test_release.py` | 61 | 757 | Release policy and registry verification (#481): strict project-name and version parsing/comparison, version-bump/TestPyPI/recovery planning, exact confirmation and immutable-tag guards, first-parent version-introduction discovery, package-change recovery refusal, non-empty CHANGELOG extraction, one-wheel/one-sdist SHA-256 manifests, malformed registry-response handling, missing/filename/hash propagation retries, exact filename/hash verification, and CLI dispatch/GitHub-output wiring. An autouse fixture scrubs hook-exported `GIT_*` variables so the tmp-repo git calls (fixture helpers and `release.py`'s own) never resolve to the developer's repository when the suite runs inside a pre-commit hook. | +| `test_check_doc_counts.py` | 87 | 943 | `check_doc_counts.py`'s pure per-document checks: KNOWN_ISSUES refactoring line counts (±10% tolerance band incl. the exact-boundary case, drift detection, empty-file citation, hyphenated paths, missing file/section/rows, the #419 empty-section sentinel + its cannot-mask-a-malformed-table dual), HISTORY version-row format (issue-link limit, ` — ` separator rejection, dateless-row and prose exemption, line-number reporting), the TESTING.md tests breakdown (parts summing to the collected total, a self-consistent-but-stale row, and the reworded-row error), and vera/README.md's Test Suite counts (all four checked independently — mutation-validated by dropping each citation in turn — plus the reworded-paragraph error, a thousands-separator case pinning that every one of the four counts is read comma-tolerantly, and the two section-anchoring cases — a reworded paragraph with decoy counts in a later section, and a renamed heading, both of which must fail loud rather than match across the section boundary). The reworded case is a test in its own right for both new checks: a pattern that matches nothing must be an error, or rewording the sentence silently switches the gate off. Also the release count (README's status line and HISTORY's total against each other and against `git tag`: the matching case, the one-ahead release cut that `release.yml` has not tagged yet, that +1 being the ONLY slack once the version is tagged, the two-behind drift that actually shipped, per-document reporting, and a tagless checkout standing the oracle down without standing down the cross-check), plus the tag reader itself against real repositories built in `tmp_path` — release tags read, `nightly`/`-rc1` not counted as releases, and both no-evidence answers (`None` rather than `[]`, since an empty list would read as zero releases and make every documented count wrong) for a tagless checkout and a directory that is not a repository at all. CONTRIBUTING.md's pre-commit hook count is checked the same way, reworded-sentence case included; and the CI-pipeline lint row against `ci.yml`'s lint job — a matching row, a step present in CI but absent from the row (the drift that shipped), a row entry CI no longer runs, the same set in a different order, a reworded row and a renamed job (both errors, not skips), that only the lint job is read rather than the whole workflow, and the shipped pair both clean and red with one entry dropped | +| `test_grammar_alignment.py` | 74 | 533 | `check_grammar_alignment.py` gate ([#683](https://github.com/aallan/vera/issues/683)): both extractors (Lark headers with the `?`/`!`/`_` markers stripped, template parameters and rule priorities tolerated, and `-> alias` names deliberately not collected; spec headers from ```ebnf fences only), the allowlist arithmetic in all three directions — unwaived drift, a spent entry both files now have, and an entry whose premise broke — one case per waiver proving the fact it rests on is actually checked, the name-deleted-from-both-files case that must not read as agreement, a mutation restoring the spec's old `assert_stmt` name, non-vacuous extraction, and the false positive the issue itself rested on: `qualified_call` and `module_call` are spec headers Lark expresses as aliases, and must never be reported as drift. Three pin the premise checks against ways they used to pass vacuously: an alias surviving only inside a `//` comment must not hold its waiver up, an alias that moved to another rule must fail the waiver naming `fn_call`, and a spent waiver whose premise also broke must yield one instruction rather than two opposite ones | +| `test_check_examples_run.py` | 75 | 1,226 | `check_examples_run.py`, the harness gate that RUNS the examples. Five separable parts, each in both directions. **The coverage rule** — the shipped tables cover the shipped corpus exactly, and an unclassified example, a stale `RUN_SPECS` or `SKIPS` key whose file is gone, a name in both tables, and a skip citing an undocumented property are each an error; the empty corpus is an error too, since a glob that stops matching would otherwise report success over zero programs. Plus the specs' own well-formedness: every named entry point is `public` in its example, every no-main example pins one (or `vera run` would fall back to an arbitrary first export), and no skip property is unused. **The runner** — a seeded `tmp_path` corpus proves it goes red on a program that type-checks and compiles but traps at run time, green on one that does not, and reports only the broken member of a mixed pair; a fixture whose first export is clean and whose named one traps proves `spec.fn` is actually honoured rather than ignored; a writer program proves each run gets a scratch working directory, so a gate run leaves nothing beside the examples. **The TESTING.md cross-check** — missing row, extra row, rename (reported naming both sides), wrong disposition and wrong skip property are errors, the parse stops at the next heading so a row-shaped line in a later section is not swept in, and both a reworded heading and a heading whose table has vanished fail loud rather than finding nothing to compare. **The output signal** -- the second half of the two-signal discipline `check_examples.py` established: the fallback note that `vera run` prints when it cannot use the named entry point is a failure even at exit 0, an absent `expect` sentinel is a failure even at exit 0, and a spec without one asserts nothing about output; end to end, a privatised `main` and a program that completed down a graceful arm each go red, and the same program passes once its own output is the sentinel, so the check reads the output rather than always failing. Which specs must carry a sentinel is **derived** from what each example declares — a resource effect in a function's effect row, or a call to a resource operation, both validated against the live effect registry so a renamed effect or op fails loudly — and asserted equal to the specs that have one, in both directions; the previous hard-coded triple could not see a fourth such example arriving without one. The runner's use of BOTH streams is pinned structurally -- `vera run` writes the note to stderr and nothing there on a clean exit, so no fixture can distinguish reading both streams from reading stdout alone, and a tripwire wired to the wrong stream is no tripwire. Also the hermetic-environment property: an ambient `VERA_DB_URL` or provider key is stripped so a gate run cannot be pointed at a real database or turned into a billed API call, a fixture spec puts its own URL back, and every neutralised name is checked to be one `vera/runtime/` actually reads | +| `test_check_corpus_differential.py` | 28 | 498 | `check_corpus_differential.py`, the burndown instrument that compiles the corpus at two revisions. The pure pieces only — the real two-revision run costs minutes and is not a test. **Classification**, all four verdicts: identical, WAT differs, and each one-sided compile failure as its own kind, since a compilability reversal reported as a text difference is the mis-description the instrument exists to avoid; failing at both revisions is not a mover and is counted separately, so a green run states how much of it was vacuous. **Enumeration** — recursive, keyed by repo-relative POSIX path, and an empty corpus is an error rather than a clean run over nothing. **The canary** — each side must import the compiler it was pointed at, so a side silently resolving to the venv's editable install cannot compare a revision against itself; an import failure and a foreign compiler are different messages. **Reporting** — every mover named with its reason, the exit code, the `--json` shape, and a program missing from one side reported rather than dropped. One test asserts the instrument is absent from `.pre-commit-config.yaml`, so the docstring's claim cannot rot | | `test_check_editor_grammars.py` | 20 | 249 | `check_editor_grammars.py` gate ([#1156](https://github.com/aallan/vera/issues/1156)): the registry read (every effect in, every ability out, and the four names the grammars actually drifted on present so the set checked is non-vacuous), the word-boundary presence test across all three grammar formats (JSON, plist XML, Vim keyword list) including the prefix pair `Http`/`HttpServer` in both directions, the deliberate comment-mention false pass, a metacharacter pair that only passes when the name is matched literally (`A.B` present, `A0B` not), and the empty registry; the gate's primary path end to end — a listed grammar with an effect stripped out, and a listed README with one stripped out of its prose bullet, each red against an otherwise-clean mirrored tree; the completeness guard — a grammar discovered under `editors/` but absent from `GRAMMARS` fails that same tree, over three discovery routes (`.el` outside a syntax directory, a tree-sitter `.scm` query set, a `.tmLanguage.json` filed anywhere but `syntaxes/`); and the registry's provenance, run as a subprocess against a throwaway checkout whose `vera` package names an effect the grammars do not, which is the only way to see that the list comes from the tree being checked rather than from site-packages. The shipped grammars and READMEs are currently clean | | `test_check_explicit_encoding.py` | 54 | 254 | `check_explicit_encoding.py` gate (#645): flags text-mode `open()` / `read_text()` / `write_text()` **and** `subprocess.run/Popen/check_output(..., text=True)` captures missing an `encoding="utf-8"` literal (rejects non-literal / non-UTF-8 values), skips binary/bytes-mode calls, honours the `# encoding-exempt` opt-out, and asserts the shipped repo is clean | | `test_check_limitations_sync.py` | 6 | 108 | `check_limitations_sync.py` section extraction: table-rows-only issue harvesting, prose-link exemption, bounding at the next second-level heading, `None` for absent or sub-level headings so renamed sections fail loudly; plus the #852 fail-loud rule: an UNKNOWN issue state under `--check-states` (gh missing / auth failure / timeout) is an error, never a silent pass | @@ -995,9 +996,9 @@ Twenty-nine scripts in `scripts/` validate cross-cutting concerns beyond unit te | `check_diagnostic_fields.py` | Every diagnostic in `vera/` carries rationale + spec_ref, and errors also a `fix` (warnings exempt); every present spec_ref resolves to a real spec section; every literal `error_code` is registered in `ERROR_CODES` (#828); `# diag-fields-exempt: ` waives missing/unresolvable fields only — never a wrong-but-resolving spec_ref or an unregistered error_code (#682, #955) | | `check_explicit_encoding.py` | Every text-mode `open()` / `read_text()` / `write_text()`, `subprocess.run/Popen/check_output` text capture, and text-mode `tempfile.NamedTemporaryFile` under `vera/`, `scripts/` and `tests/` passes an explicit `encoding="utf-8"`; `# encoding-exempt: ` opts a deliberate non-UTF-8 site out (#645) | | `check_e602_clean.py` | No unexpected E602/E604 silent-skip sites outside the explicit allowlist | -| `check_examples_run.py` | Every `examples/*.vera` either runs trap-free under the native runtime or carries a documented skip property. Two signals, as in `check_examples.py`: the exit code, and an output signal — every spec names its entry point (so a privatised or renamed `main` exits 1 instead of silently running another export) and the three environment-dependent examples pin a success sentinel (so a vanished fixture fails rather than passing on a graceful arm). An unclassified example is an error, and TESTING.md's execution-coverage table must match the script's own classification | +| `check_examples_run.py` | Every `examples/*.vera` either runs trap-free under the native runtime or carries a documented skip property. Two signals, as in `check_examples.py`: the exit code, and an output signal — every spec names its entry point (so a privatised or renamed `main` exits 1 instead of silently running another export) and every example that declares a resource effect or calls a resource operation pins a success sentinel (so a vanished fixture fails rather than passing on a graceful arm), the set being derived from those declarations rather than named. An unclassified example is an error, and TESTING.md's execution-coverage table must match the script's own classification | | `check_doc_builtin_shadowing.py` | No documentation example defines a function named after an opaque verifier-modelled built-in (would fail `vera check` with E151); the `spec/09` signature reference is exempt ([#819](https://github.com/aallan/vera/issues/819)) | -| `check_grammar_alignment.py` | Every rule header in `spec/10-grammar.md`'s EBNF has a same-named rule in `vera/grammar.lark`, and the reverse. Names only — rule bodies are not compared ([#683](https://github.com/aallan/vera/issues/683)) | +| `check_grammar_alignment.py` | Every rule header in `spec/10-grammar.md`'s EBNF has a same-named rule in `vera/grammar.lark`, and the reverse ([#683](https://github.com/aallan/vera/issues/683)); every terminal is declared and referenced within its own file, every regex-bodied terminal carries the same pattern in both, and each shared production's right-hand side refers to the same rules and terminals ([#1290](https://github.com/aallan/vera/issues/1290)). The *shape* of a right-hand side — alternation, grouping, repetition — is still not compared | | `check_editor_grammars.py` | Every editor grammar under `editors/` (vscode, TextMate, Vim), and the two extension READMEs that repeat the list in prose, carries every built-in effect name from the live registry — read from the checked-out tree, not from whatever `vera` is importable. Word-boundary presence: absence is conclusive, presence is optimistic — the observed failure is omission. A completeness guard fails any grammar discovered under `editors/` that the checked list doesn't name ([#1156](https://github.com/aallan/vera/issues/1156)) | | `check_distribution.py` | The built wheel and sdist carry the project's own name and version, ship the files the installed package needs plus a packaged LICENSE, and exclude `tests/` and generated Python files | | `check_wheel_availability.py` | Every runtime dependency ships wheels for all supported platforms | @@ -1006,6 +1007,8 @@ Twenty-nine scripts in `scripts/` validate cross-cutting concerns beyond unit te Each runs in its configured pre-commit hook or CI job, so issues are caught locally before they reach the remote; `build_site.py` is the generator whose output `check_site_assets.py` verifies. +One script is deliberately outside that set. `check_corpus_differential.py` compiles every corpus program at two revisions and reports the ones whose WAT moved, including the ones that compile on only one side — the measurement behind a "codegen is unchanged" claim, and the scope list when output is meant to change. It costs minutes rather than milliseconds, so it is a burndown instrument run by hand (`--base-ref origin/main`), not a hook and not a CI gate; a test asserts its absence from `.pre-commit-config.yaml` so that claim cannot rot. `check_doc_counts.py --check-bug-issues` is opt-in for the same kind of reason — it needs the GitHub API, which a commit hook must not — and belongs to the release PR (see `RELEASING.md`). + ### Spec validation pipeline `check_spec_examples.py` pushes spec code blocks through three compiler stages. A block that intentionally fails a stage carries an inline annotation on the line before its fence — `` (or `vera:skip-check` / `vera:skip-verify`; see `scripts/doc_annotations.py` and [#538](https://github.com/aallan/vera/issues/538)): @@ -1095,7 +1098,7 @@ The repository configures 36 hooks across two stages: 34 run at the commit stage | `check_pypi_readme_examples.py` | PYPI_README.md code blocks parse, check, and verify | | `check_html_examples.py` | HTML landing page code blocks pass parse + check + verify | | `check_doc_builtin_shadowing.py` | No doc example defines a function named after an opaque built-in (would fail `vera check` with E151); `spec/09` signature reference exempt ([#819](https://github.com/aallan/vera/issues/819)) | -| `check_grammar_alignment.py` | Spec EBNF and Lark grammar agree on every rule name ([#683](https://github.com/aallan/vera/issues/683)) | +| `check_grammar_alignment.py` | Spec EBNF and Lark grammar agree on every rule name ([#683](https://github.com/aallan/vera/issues/683)), every terminal, and the symbols each shared production refers to ([#1290](https://github.com/aallan/vera/issues/1290)) | | `check_editor_grammars.py` | Every editor grammar under `editors/`, and the two extension READMEs, carry every built-in effect name from the live registry ([#1156](https://github.com/aallan/vera/issues/1156)) | | `check_e602_clean.py` | No unexpected `[E602]` (body unsupported) / `[E604]` (param unsupported) silent skips outside the explicit allowlist (Layer 1 of [#626](https://github.com/aallan/vera/issues/626)) | | `check_examples_run.py` | Every example runs trap-free (exit code plus an output signal) or carries a documented skip property, and TESTING.md's execution-coverage table matches | diff --git a/docs/llms-full.txt b/docs/llms-full.txt index bb31edc9..c1cdaa02 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -3265,7 +3265,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 11,786 tests, including a 244-program conformance suite +- 11,907 tests, including a 244-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/scripts/check_corpus_differential.py b/scripts/check_corpus_differential.py new file mode 100644 index 00000000..29349807 --- /dev/null +++ b/scripts/check_corpus_differential.py @@ -0,0 +1,728 @@ +#!/usr/bin/env python +"""Burndown instrument: compile every corpus program at two revisions +and report which ones MOVED. + + python scripts/check_corpus_differential.py --base-ref origin/main + +**This is not a pre-commit hook and not a CI gate.** It compiles the +whole corpus twice — once with the working tree's compiler, once with +the compiler at ``--base-ref`` — so a run costs minutes, not the +milliseconds a commit hook may spend. It is deliberately absent from +``.pre-commit-config.yaml``, and `tests/test_check_corpus_differential.py` +asserts that absence so the claim cannot rot. Run it by hand when the +question it answers is the one you have. + +That question is: **did this change move any compiled output, and if so, +exactly which programs?** It has two uses, and they are the same +measurement read in opposite directions: + +- *Proving a change inert.* A refactor, a rename, a whitelist + reshuffle — the claim "codegen is unchanged" is otherwise an argument + from reading the diff. Zero movers over the whole corpus is evidence. + PR #1323 made exactly this claim with an ad-hoc version of this + script; promoting it means the next such claim is reproducible rather + than re-improvised. +- *Enumerating what a change moved.* When output is meant to change, + the mover list is the scope of the change, program by program — + including the programs nobody expected it to reach. + +The comparison surface is the **WAT text** (`vera compile --wat`), which +is what "byte-identical WAT" meant in the PR #1323 record, compared by +SHA-256 digest. Four verdicts per program, from two compiles: + +| base | head | verdict | +|-----------|-----------|-------------------------------| +| same WAT | same WAT | not a mover | +| WAT A | WAT B | mover — `WAT differs` | +| failed | compiled | mover — `compiles only at HEAD` | +| compiled | failed | mover — `compiles only at ` | +| failed | failed | not a mover, counted separately | + +The two one-sided-failure rows are the reason this is not a `diff` over +saved WAT files. A program whose compilability *reverses* has no WAT on +one side, and a comparison that only knows "same text / different text" +reports that as a text difference — which is the class the PR #1323 +record called out as having been mis-described. They are distinct +verdicts here, and each names the direction. + +The both-failed row is counted and printed rather than folded into +agreement. The corpus deliberately contains negative fixtures that fail +to compile at every revision; they agree vacuously, and a reader of a +green run is entitled to know how much of it was actually measured. + +**How the two sides are built.** The corpus is the *working tree's* +`.vera` files, and *both* sides compile those same files — only the +compiler differs. That isolates a compiler change from a corpus change: +a program edited in the working tree is compiled from its edited text on +both sides, so it moves only if the compiler moved under it. A program +using a feature the base compiler lacks shows up as `compiles only at +HEAD`, which is the true verdict. + +The head side is the working tree as it stands, uncommitted edits +included. The base side is materialised with ``git worktree add +--detach`` into ``--work-dir`` and driven through its *own* checkout: the +subprocess runs with ``cwd`` and ``PYTHONPATH`` set to that directory, so +``python -m vera.cli`` there resolves ``vera`` to the base revision's +package. Each side is probed first (`canary_error`) to confirm it +imported the compiler it was supposed to: the venv may carry an editable +install of a *third* checkout, and a side that silently resolved to it +would compare a revision against itself and report zero movers — +a green verdict that measured nothing. + +**The base checkout is left on disk.** It is keyed by the base commit's +SHA and reused by later runs against the same revision, so repeated runs +pay for one checkout per revision rather than one per run. Its path is +printed on every run. Removing it is the caller's business: + + git worktree remove # or: git worktree prune + +Requires the base revision's compiler to run under the *current* venv's +installed dependencies — the base checkout supplies `vera/`, not its own +site-packages. Across a dependency bump this instrument compares what +the current environment can run, which is worth knowing before reading +its verdict. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import subprocess +import sys +import tempfile +from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import NamedTuple + + +# The corpus: everything `vera check` can reach under these roots, at any +# depth. `examples/vera/` and `tests/conformance/vera/` hold the modules +# the top-level programs import — a non-recursive glob would compare a +# program while ignoring the source it is built from, the gap +# `scripts/check_corpus_canonical.py` records having had. +_CORPUS_DIRS = ("examples", "tests/conformance") + +# Per-file compile budget. Generous — a corpus program compiles in well +# under a second — so this only fires on a genuine hang, and a hang on +# one side is reported as that side failing rather than blocking the run. +DEFAULT_TIMEOUT_SECONDS = 120 + +# The first line of a Vera error diagnostic: `[E154] Error at , +# line N, column M:` — or the same without a code, which a few carry. +# Anchored so a warning's message body, which quotes neither, cannot +# match. +_ERROR_MARKER = re.compile(r"^(\[E\d+\]\s*)?Error\b") + + +# --------------------------------------------------------------------------- +# Data +# --------------------------------------------------------------------------- + +# ``NamedTuple`` rather than ``@dataclass`` throughout: this module is +# loaded by its tests with the bare ``spec_from_file_location`` / +# ``exec_module`` recipe, which leaves the module unregistered in +# ``sys.modules`` — and ``@dataclass`` resolves its annotations through +# ``sys.modules[cls.__module__]``. Same reason as +# `scripts/check_examples_run.py`. + + +class Artifact(NamedTuple): + """One program's compiled output at one revision. + + ``digest`` is the SHA-256 of the WAT text and is ``None`` exactly + when ``ok`` is False — there is no artifact to compare, and the + reason lives in ``error``. + """ + + ok: bool + digest: str | None + size: int + error: str + + +class Mover(NamedTuple): + """A program whose compiled output changed between the revisions.""" + + path: str + kind: str + reason: str + + +class Comparison(NamedTuple): + """The whole corpus, classified. + + ``compared`` counts the programs both sides reported on, and + partitions exactly into ``identical + both_failed + len(movers)``. + ``unreported`` holds programs only one side reported on at all — a + truncated run, which is a failure rather than a quiet shortfall. + """ + + movers: list[Mover] + compared: int + identical: int + both_failed: int + unreported: list[str] + + +class RunInfo(NamedTuple): + """What the run compared, for the report and the JSON envelope.""" + + base_ref: str + base_sha: str + base_root: str + head_root: str + + +# --------------------------------------------------------------------------- +# The corpus +# --------------------------------------------------------------------------- + + +def corpus_files(root: Path) -> list[Path]: + """Every corpus program under `root`, at any depth, in path order.""" + files: list[Path] = [] + for directory in _CORPUS_DIRS: + files.extend(sorted((root / directory).rglob("*.vera"))) + return files + + +def corpus_guard(files: list[Path], root: Path) -> str | None: + """Refuse to run on an empty corpus; ``None`` when there is one. + + A differential over zero programs finds zero movers, and zero movers + is this instrument's success verdict — so an enumeration that stops + matching would report "nothing moved" over nothing at all, which is + the single failure mode most likely to be believed. + """ + if files: + return None + return ( + f"could not find any .vera programs under {root} " + f"({', '.join(_CORPUS_DIRS)}). This is an error rather than a " + f"clean run: a differential over an empty corpus reports zero " + f"movers, which is indistinguishable from a change that moved " + f"nothing." + ) + + +# --------------------------------------------------------------------------- +# Classification +# --------------------------------------------------------------------------- + + +def classify( + base: Artifact, head: Artifact, base_label: str +) -> tuple[str, str] | None: + """``(kind, reason)`` when this program moved, ``None`` when it did + not. + + Compilability is checked before the digests, because a program that + compiles at only one revision has no artifact to compare and must be + named for the *direction* it moved in — reporting it as a text + difference is the mis-description PR #1323's record calls out. + """ + if base.ok and head.ok: + if base.digest == head.digest: + return None + return ( + "wat-differs", + f"WAT differs (at {base_label}: {_short(base.digest)}, " + f"{base.size} bytes; at HEAD: {_short(head.digest)}, " + f"{head.size} bytes)", + ) + + if head.ok and not base.ok: + return ( + "head-only", + f"compiles only at HEAD (at {base_label} it failed: " + f"{base.error})", + ) + + if base.ok and not head.ok: + return ( + "base-only", + f"compiles only at {base_label} (at HEAD it failed: " + f"{head.error})", + ) + + # Neither side produced an artifact — the negative conformance + # fixtures live here. Not a mover; counted separately by `compare` + # so the agreement it contributes is never read as measurement. + return None + + +def _short(digest: str | None) -> str: + return "none" if digest is None else digest[:12] + + +def compare( + base: dict[str, Artifact], head: dict[str, Artifact], base_label: str +) -> Comparison: + """Classify every program both sides reported on.""" + movers: list[Mover] = [] + identical = 0 + both_failed = 0 + compared = 0 + + for path in sorted(set(base) & set(head)): + compared += 1 + verdict = classify(base[path], head[path], base_label) + if verdict is not None: + movers.append(Mover(path=path, kind=verdict[0], reason=verdict[1])) + elif not base[path].ok and not head[path].ok: + both_failed += 1 + else: + identical += 1 + + return Comparison( + movers=movers, + compared=compared, + identical=identical, + both_failed=both_failed, + unreported=sorted(set(base) ^ set(head)), + ) + + +# --------------------------------------------------------------------------- +# Compiling one side +# --------------------------------------------------------------------------- + + +def canary_error(reported: str, root: Path, side: str) -> str | None: + """The load-bearing guard: did this side import the compiler it was + pointed at? + + Both sides run the same ``python -m vera.cli`` and differ only in + ``PYTHONPATH``/``cwd``. The venv also carries an editable install of + whichever checkout was `pip install -e`'d, reachable through a + finder on ``sys.meta_path``. A side that resolved to *that* would + compile with the wrong compiler, and the run would report zero + movers no matter what the change did. + """ + if not reported.strip(): + return ( + f"the {side} side could not import `vera` at all from {root} " + f"— the differential cannot run. Check that the checkout is " + f"intact and that the current environment satisfies its " + f"dependencies." + ) + + resolved = Path(reported.strip()).resolve() + expected = root.resolve() + if resolved == expected or expected in resolved.parents: + return None + + return ( + f"the {side} side imported {reported.strip()}, which is not under " + f"{root} — it is compiling with a different checkout's compiler, " + f"so the differential would compare a revision against itself and " + f"report zero movers. Usually an editable install shadowing the " + f"path, or a stale PYTHONPATH." + ) + + +def _side_env(root: Path) -> dict[str, str]: + """The environment one side's compiles run under. + + ``PYTHONPATH`` is *replaced*, never extended: the caller's own + ``PYTHONPATH`` frequently points at the head checkout (that is how + this repo is driven), and inheriting it on the base side would put + the head compiler first on the path — the exact vacuity + `canary_error` exists to catch. + """ + env = dict(os.environ) + env["PYTHONPATH"] = str(root) + # No .pyc into either checkout: the base one is a scratch worktree, + # and stale bytecode across revisions has bitten this project before. + env["PYTHONDONTWRITEBYTECODE"] = "1" + return env + + +def probe_compiler(python: str, root: Path) -> str: + """Where this side's `vera` package actually resolves to, or ``""``.""" + result = subprocess.run( + [python, "-c", "import vera, sys; sys.stdout.write(vera.__file__)"], + capture_output=True, + text=True, + encoding="utf-8", + cwd=str(root), + env=_side_env(root), + check=False, + ) + return result.stdout if result.returncode == 0 else "" + + +def _first_error(stderr: str, path: Path) -> str: + """The compile's reason, in one line. + + A Vera diagnostic is a *block* — marker line, quoted source, caret, + message — and only the first line carries the marker. Skipping + lines that start with ``warning:`` is therefore not enough to skip a + warning: a real run against v0.1.9 reported a warning's quoted + source line (``public fn read_some(@Unit -> @Int)``) as the reason a + program failed to compile. The error's own marker line is what to + look for. + + With no marker anywhere the compile did not produce a diagnostic at + all — it crashed. The informative line of a traceback (and of an + argparse usage error) is the last, not the first. + + The program's own path is stripped back to its name: the reason is + already attached to a named program, and a corpus file's absolute + path under a scratch checkout is long enough on its own to push the + diagnostic past the truncation. + """ + lines = [line.strip() for line in stderr.splitlines()] + nonempty = [line for line in lines if line] + + for line in nonempty: + if _ERROR_MARKER.match(line): + reason = line + break + else: + reason = nonempty[-1] if nonempty else "compile failed with no output" + + return reason.replace(str(path), path.name)[:160] + + +def compile_one( + python: str, compiler_root: Path, timeout: int, path: Path +) -> Artifact: + """Compile one program with one side's compiler. + + Deliberately the CLI rather than the codegen API: ``vera compile + --wat`` is the surface that holds its shape across revisions, and + this script runs unchanged against a compiler whose internals it may + predate. A failure is *data* — the failure-direction verdicts are + half of what the instrument measures — so nothing here raises. + """ + try: + result = subprocess.run( + [python, "-m", "vera.cli", "compile", "--wat", str(path)], + capture_output=True, + text=True, + encoding="utf-8", + cwd=str(compiler_root), + env=_side_env(compiler_root), + stdin=subprocess.DEVNULL, + timeout=timeout, + check=False, + ) + except subprocess.TimeoutExpired: + return Artifact( + ok=False, digest=None, size=0, + error=f"compile exceeded the {timeout}s budget", + ) + except OSError as exc: # the interpreter or checkout is not usable + return Artifact( + ok=False, digest=None, size=0, error=f"could not run: {exc}", + ) + + if result.returncode != 0: + return Artifact( + ok=False, digest=None, size=0, + error=_first_error(result.stderr, path), + ) + + wat = result.stdout + digest = hashlib.sha256(wat.encode("utf-8")).hexdigest() + return Artifact(ok=True, digest=digest, size=len(wat), error="") + + +def collect( + files: list[Path], + corpus_root: Path, + compile_fn: Callable[[Path], Artifact], + jobs: int = 1, +) -> dict[str, Artifact]: + """Compile every file, keyed by its path relative to `corpus_root`. + + Both sides compile the *same* files — the working tree's — so both + maps are keyed against the same root and line up by construction. + An absolute key would not: the base compiler runs from a scratch + checkout, and keying by anything side-specific would leave every + program unreported. POSIX form because the key is compared as a + string (CLAUDE.md's cross-platform rule). + """ + keys = [_key(path, corpus_root) for path in files] + if jobs <= 1: + results = [compile_fn(path) for path in files] + else: + with ThreadPoolExecutor(max_workers=jobs) as pool: + # `map` yields in input order, so the zip below cannot + # misattribute a result to the wrong program. + results = list(pool.map(compile_fn, files)) + return dict(zip(keys, results, strict=True)) + + +def _key(path: Path, corpus_root: Path) -> str: + try: + return path.relative_to(corpus_root).as_posix() + except ValueError: + return path.as_posix() + + +# --------------------------------------------------------------------------- +# The base checkout +# --------------------------------------------------------------------------- + + +def resolve_ref(repo_root: Path, ref: str) -> str | None: + """The commit SHA `ref` names, or ``None`` when git cannot resolve it.""" + result = subprocess.run( + ["git", "-C", str(repo_root), "rev-parse", "--verify", f"{ref}^{{commit}}"], + capture_output=True, + text=True, + encoding="utf-8", + check=False, + ) + return result.stdout.strip() if result.returncode == 0 else None + + +def base_checkout( + repo_root: Path, sha: str, work_dir: Path +) -> tuple[Path | None, str]: + """A checkout of `sha`, materialised under `work_dir` if need be. + + Returns ``(path, "")`` or ``(None, error)``. Named by SHA and + reused when it is already there, so a burndown session that runs the + differential repeatedly against one base pays for one checkout. It + is never removed — see the module docstring. + """ + dest = work_dir / f"vera-base-{sha[:12]}" + + if dest.exists(): + current = subprocess.run( + ["git", "-C", str(dest), "rev-parse", "HEAD"], + capture_output=True, + text=True, + encoding="utf-8", + check=False, + ) + if ( + current.returncode == 0 + and current.stdout.strip() == sha + and (dest / "vera" / "__init__.py").is_file() + ): + return dest, "" + return None, ( + f"{dest} already exists but is not a clean checkout of {sha[:12]} " + f"— this script never deletes it. Move it aside, or pass a " + f"different --work-dir." + ) + + work_dir.mkdir(parents=True, exist_ok=True) + result = subprocess.run( + ["git", "-C", str(repo_root), "worktree", "add", "--detach", + str(dest), sha], + capture_output=True, + text=True, + encoding="utf-8", + check=False, + ) + if result.returncode != 0: + return None, ( + f"could not create a worktree for {sha[:12]} at {dest}: " + f"{result.stderr.strip() or result.stdout.strip()}" + ) + return dest, "" + + +# --------------------------------------------------------------------------- +# Reporting +# --------------------------------------------------------------------------- + + +def json_payload(info: RunInfo, comparison: Comparison) -> dict[str, object]: + return { + "ok": not comparison.movers and not comparison.unreported, + "base_ref": info.base_ref, + "base_sha": info.base_sha, + "base_root": info.base_root, + "head_root": info.head_root, + "compared": comparison.compared, + "identical": comparison.identical, + "both_failed": comparison.both_failed, + "movers": [m._asdict() for m in comparison.movers], + "unreported": comparison.unreported, + } + + +def summary_lines(info: RunInfo, comparison: Comparison) -> list[str]: + """The stdout summary — what was compared, and how it partitioned.""" + return [ + f"Corpus differential: {comparison.compared} programs compiled at " + f"both revisions.", + f" base: {info.base_ref} ({info.base_sha[:12]}) -> {info.base_root}", + f" head: working tree -> {info.head_root}", + f" identical WAT: {comparison.identical}", + f" compiled at neither revision: {comparison.both_failed} " + f"(vacuous agreement — nothing was compared for these)", + f" movers: {len(comparison.movers)}", + ] + + +def failure_lines(info: RunInfo, comparison: Comparison) -> list[str]: + """The stderr report: every mover, then what to do about it.""" + lines: list[str] = [] + if comparison.movers: + lines.append(f"MOVERS ({len(comparison.movers)}):") + lines += [f" {m.path}: {m.reason}" for m in comparison.movers] + lines += [ + "", + "Each line is a program whose compiled output changed between " + f"{info.base_ref} and the working tree. If the change under " + "test was meant to be inert, these are its counter-examples; if " + "it was meant to move output, this is the enumeration of what " + "it moved. Reproduce one with:", + "", + " vera compile --wat # working tree", + f" (cd {info.base_root} && vera compile --wat )", + ] + if comparison.unreported: + if lines: + lines.append("") + lines.append(f"UNREPORTED ({len(comparison.unreported)}):") + lines += [f" {path}" for path in comparison.unreported] + lines += [ + "", + "The two sides did not report on the same set of programs, so " + "the run is truncated rather than clean — its verdict covers " + "less than the corpus. Usually a side that crashed partway.", + ] + return lines + + +def emit(info: RunInfo, comparison: Comparison, *, as_json: bool) -> int: + """Print the verdict; return the exit code (0 clean, 1 moved).""" + payload = json_payload(info, comparison) + if as_json: + print(json.dumps(payload, indent=2)) + return 0 if payload["ok"] else 1 + + for line in summary_lines(info, comparison): + print(line) + + lines = failure_lines(info, comparison) + if lines: + print("", file=sys.stderr) + for line in lines: + print(line, file=sys.stderr) + return 1 + + print( + f"\nNo movers: the working tree's compiled output is identical to " + f"{info.base_ref}'s across the corpus." + ) + return 0 + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def _parse_args(argv: list[str] | None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Compile the corpus at two revisions and report which " + "programs moved. Burndown instrument — not a hook." + ), + ) + parser.add_argument( + "--base-ref", default="origin/main", + help="revision to compare the working tree against " + "(default: origin/main)", + ) + parser.add_argument( + "--work-dir", + default=str(Path(tempfile.gettempdir()) / "vera-corpus-differential"), + help="where the base revision is checked out; the checkout is " + "keyed by SHA, reused, and never deleted", + ) + parser.add_argument( + "--jobs", type=int, default=min(8, os.cpu_count() or 1), + help="parallel compiles per side (default: %(default)s)", + ) + parser.add_argument( + "--timeout", type=int, default=DEFAULT_TIMEOUT_SECONDS, + help="per-program compile budget in seconds (default: %(default)s)", + ) + parser.add_argument( + "--json", action="store_true", dest="as_json", + help="emit the verdict as JSON on stdout", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = _parse_args(argv) + repo_root = Path(__file__).resolve().parent.parent + + files = corpus_files(repo_root) + problem = corpus_guard(files, repo_root) + if problem is not None: + print(f"ERROR: {problem}", file=sys.stderr) + return 1 + + sha = resolve_ref(repo_root, args.base_ref) + if sha is None: + print( + f"ERROR: git cannot resolve --base-ref {args.base_ref!r} to a " + f"commit in {repo_root}. Fetch it first (`git fetch origin`), " + f"or name a revision that exists locally.", + file=sys.stderr, + ) + return 1 + + base_root, problem = base_checkout(repo_root, sha, Path(args.work_dir)) + if base_root is None: + print(f"ERROR: {problem}", file=sys.stderr) + return 1 + print(f"Base checkout (left in place): {base_root}", file=sys.stderr) + + # Both canaries before either side's corpus run: a side pointing at + # the wrong compiler makes the whole differential vacuous, and that + # must be a refusal rather than a green run. + for side, root in (("head", repo_root), ("base", base_root)): + problem = canary_error(probe_compiler(sys.executable, root), root, side) + if problem is not None: + print(f"ERROR: {problem}", file=sys.stderr) + return 1 + + info = RunInfo( + base_ref=args.base_ref, + base_sha=sha, + base_root=str(base_root), + head_root=str(repo_root), + ) + + sides: dict[str, dict[str, Artifact]] = {} + for side, root in (("base", base_root), ("head", repo_root)): + print( + f"Compiling {len(files)} programs with the {side} compiler " + f"({root})...", + file=sys.stderr, + ) + sides[side] = collect( + files, + repo_root, + lambda path, root=root: compile_one( + sys.executable, root, args.timeout, path + ), + jobs=args.jobs, + ) + + return emit( + info, + compare(sides["base"], sides["head"], args.base_ref), + as_json=args.as_json, + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check_doc_counts.py b/scripts/check_doc_counts.py index 8d45b8c4..ce7d9e88 100644 --- a/scripts/check_doc_counts.py +++ b/scripts/check_doc_counts.py @@ -11,16 +11,23 @@ (one issue link max, no " — " separator per row), the vera/README.md module map (#1150) and its Test Suite paragraph's four counts, the project facts hardcoded on the landing page (#528), and the cited corpus-program -count. +count. Three more were added for #1290: every figure on README's +project-status line rather than only its test count; TESTING.md's dual-target +conformance row, whose split and category counts come from a live run of the +differential itself; and the shape of KNOWN_ISSUES.md's Bugs table. Intentionally excludes CHANGELOG.md: its counts are historical records (e.g. "64 programs, was 63") that are frozen snapshots of the project state at each release. Validating them would cause false positives on every new conformance addition, because the old entries are supposed to stay unchanged. -Runs in a couple of seconds — fast enough for a pre-commit hook. +Runs in a few seconds — fast enough for a pre-commit hook. Everything it +does is local: the one check that needs the GitHub API, the Bugs table +against the open `bug`-labelled issues, is opt-in behind --check-bug-issues, +for the release PR. A commit hook must not depend on a network call. """ +import argparse import json import os import re @@ -28,6 +35,8 @@ import sys import tomllib from pathlib import Path +from typing import NamedTuple +from urllib.request import Request, urlopen def check_refactoring_counts(known_issues_text: str, root: Path) -> list[str]: @@ -825,7 +834,309 @@ def check_module_map(readme_text: str, root: Path) -> list[str]: return errors +# --------------------------------------------------------------------------- +# README's project-status line +# +# One sentence carries six live figures and the oracle read one of them. The +# `check_readme` closure it used returned silently when a pattern matched +# nothing, and four of its five patterns matched nothing at all — so the +# conformance count beside the gated tests count drifted through two rebases +# unseen. Every figure on the line is gated here, and a figure that has gone +# missing is an error rather than a skip. +# --------------------------------------------------------------------------- + +_STATUS_LINE = re.compile(r"^.*?\btests, \d+% Python code coverage.*$", re.M) +_STATUS_FIGURES = ( + (r"([\d,]+) tests,", "tests"), + (r"([\d,]+) conformance programs", "conformance programs"), + (r"([\d,]+) examples", "examples"), + (r"(\d+)-chapter specification", "spec chapters"), +) + + +def check_project_status( + readme_text: str, + live_tests: int, + live_conformance: int, + live_examples: int, + live_chapters: int, +) -> list[str]: + """Check every count on README.md's project-status line.""" + line = _STATUS_LINE.search(readme_text) + if line is None: + return [ + "README.md: could not find the project-status line " + "(`… tests, N% Python code coverage …`)" + ] + expected = (live_tests, live_conformance, live_examples, live_chapters) + errors: list[str] = [] + for (pattern, label), live in zip(_STATUS_FIGURES, expected, strict=True): + found = re.search(pattern, line.group(0)) + if found is None: + errors.append( + f"README.md project-status line: could not find the {label} count" + ) + continue + cited = int(found.group(1).replace(",", "")) + if cited != live: + errors.append( + f"README.md project-status {label}: doc says {cited}, live is {live}" + ) + return errors + + +# --------------------------------------------------------------------------- +# TESTING.md's dual-target conformance row +# +# The row states a run-level total, a tested/skipped split and three category +# counts. The total has an oracle in the conformance manifest; the rest had +# none, and the row explicitly claims the excluded set is "defined by those +# three properties rather than by a filename list, so it stays accurate as +# programs are added" — a claim that only holds if something measures it. The +# split comes from a live `-rs` run of the differential, about three seconds. +# --------------------------------------------------------------------------- + + +class DualTargetSplit(NamedTuple): + """What a live run of the dual-target differential actually did.""" + + tested: int + skipped: int + families: int + no_main: int + nondeterministic: int + + +_DUAL_TARGET_TEST = "tests/test_wasi_target.py::TestDualTargetConformance" +_SKIP_REASONS = ( + ("families", "host famil"), + ("no_main", "zero-argument"), + ("nondeterministic", "nondeterministic ops"), +) +_SKIP_LINE = re.compile(r"^SKIPPED \[(\d+)\] (.*)$", re.M) +_PYTEST_SUMMARY = re.compile(r"(\d+) passed, (\d+) skipped") +_DUAL_TARGET_FIGURES = ( + ("tested", r"(\d+) are dual-tested"), + ("skipped", r"and (\d+) skip"), + ("families", r"(\d+) whose compiled WAT"), + ("no_main", r"(\d+) with no public zero-argument"), + ("nondeterministic", r"and (\d+) calling a nondeterministic op"), +) + + +def parse_dual_target_report(report: str) -> DualTargetSplit | None: + """Read a split out of pytest's ``-rs`` output, or ``None``. + + ``None`` means the run cannot be read — no summary line, or a skip whose + reason matches none of the three documented properties. A new skip reason + is exactly the case the row's "stays accurate as programs are added" claim + needs to hear about, so it must not be silently folded into a category. + """ + summary = _PYTEST_SUMMARY.search(report) + if summary is None: + return None + counts = dict.fromkeys((name for name, _ in _SKIP_REASONS), 0) + for raw, reason in _SKIP_LINE.findall(report): + for name, marker in _SKIP_REASONS: + if marker in reason: + counts[name] += int(raw) + break + else: + return None + skipped = int(summary.group(2)) + if sum(counts.values()) != skipped: + return None + return DualTargetSplit(int(summary.group(1)), skipped, **counts) + + +def dual_target_split(root: Path) -> DualTargetSplit | None: + """Run the dual-target differential and report what it did.""" + pytest_bin = root / ".venv/bin/pytest" + if not pytest_bin.exists(): + pytest_bin = Path("pytest") + result = subprocess.run( + [str(pytest_bin), _DUAL_TARGET_TEST, "-q", "-rs", "-p", "no:randomly"], + capture_output=True, + text=True, + encoding="utf-8", + cwd=str(root), + timeout=300, + check=False, + ) + if result.returncode != 0: + return None + return parse_dual_target_report(result.stdout) + + +def check_dual_target_row( + testing_text: str, run_level_total: int, split: DualTargetSplit +) -> list[str]: + """Check TESTING.md's dual-target row against the manifest and a run.""" + errors: list[str] = [] + cited_total = re.search(r"all ([\d,]+) run-level", testing_text) + if cited_total is None: + errors.append( + "TESTING.md: could not find the dual-target run-level total " + "(`all N run-level conformance programs`)" + ) + elif int(cited_total.group(1).replace(",", "")) != run_level_total: + errors.append( + f"TESTING.md dual-target run-level total: doc says " + f"{cited_total.group(1)}, manifest has {run_level_total}" + ) + + cited: dict[str, int] = {} + for name, pattern in _DUAL_TARGET_FIGURES: + found = re.search(pattern, testing_text) + if found is None: + errors.append( + f"TESTING.md dual-target row: could not find the {name} count" + ) + continue + cited[name] = int(found.group(1)) + if cited[name] != getattr(split, name): + errors.append( + f"TESTING.md dual-target {name}: doc says {cited[name]}, " + f"a live run has {getattr(split, name)}" + ) + if len(cited) == len(_DUAL_TARGET_FIGURES): + if cited["tested"] + cited["skipped"] != run_level_total: + errors.append( + f"TESTING.md dual-target row does not add up: " + f"{cited['tested']} + {cited['skipped']} is not {run_level_total}" + ) + categories = cited["families"] + cited["no_main"] + cited["nondeterministic"] + if categories != cited["skipped"]: + errors.append( + f"TESTING.md dual-target skip categories do not add up: " + f"{categories} is not {cited['skipped']}" + ) + return errors + + +# --------------------------------------------------------------------------- +# KNOWN_ISSUES' Bugs table against the tracker +# +# The convention is one row per open `bug`-labelled issue. Two halves, and +# they are separated on purpose: the structural half is pure text and runs +# always, while the parity half needs the GitHub API and a pre-commit hook must +# not depend on a network call — it is opt-in via `--check-bug-issues`, for the +# release PR, where the tracker and the file are meant to agree. Mid-burndown +# they legitimately do not: a bug filed on an open PR's branch has an issue +# before it has a row. +# --------------------------------------------------------------------------- + +_BUGS_SECTION = re.compile(r"^## Bugs[ \t]*$(.*?)(?=^## |\Z)", re.M | re.S) +_ISSUE_LINK = re.compile(r"\[#(\d+)\]\(https://github\.com/[\w.-]+/[\w.-]+/issues/(\d+)\)") +_NO_BUGS = "No known bugs." + + +def bug_rows(known_issues_text: str) -> list[int] | None: + """Issue numbers from the Bugs table's Issue column, in order. + + The Issue column is a row's canonical tracker, and it is the only place + read: rows cross-link other issues in their prose, and counting those + would make one bug's context read as another bug's row. + + ``[]`` is the documented empty state — the section body is exactly "No + known bugs." — and ``None`` means the section could not be read at all. + The two are different problems and a caller must not conflate them. + """ + section = _BUGS_SECTION.search(known_issues_text) + if section is None: + return None + body = section.group(1).strip() + if body == _NO_BUGS: + return [] + numbers: list[int] = [] + for line in body.splitlines(): + if not line.startswith("|") or set(line) <= set("|- "): + continue + cells = [cell.strip() for cell in line.strip().strip("|").split("|")] + if cells[-1] == "Issue": + continue + # The last cell, so prose carrying a `|` cannot shift the column. + links = [ + int(number) + for number, url_number in _ISSUE_LINK.findall(cells[-1]) + if number == url_number + ] + if len(links) != 1: + return None + numbers.append(links[0]) + return numbers or None + + +def check_bug_rows(known_issues_text: str) -> list[str]: + """Check the Bugs table's shape: one well-formed, unique issue per row.""" + numbers = bug_rows(known_issues_text) + if numbers is None: + return [ + "KNOWN_ISSUES.md: the `## Bugs` table was not found, or a row's " + "Issue column does not hold exactly one `[#N](…/issues/N)` link. " + "An empty section is written `No known bugs.`" + ] + duplicates = sorted({n for n in numbers if numbers.count(n) > 1}) + return [ + f"KNOWN_ISSUES.md: issue #{number} has a Bugs row twice" + for number in duplicates + ] + + +def check_bug_issue_parity(rows: list[int], open_bugs: list[int]) -> list[str]: + """Check the Bugs table against the open `bug`-labelled issues.""" + if not open_bugs: + return [ + "KNOWN_ISSUES.md: an open `bug`-labelled issue was not found at " + "all, so the Bugs table has nothing to be checked against. An " + "empty query is a failed one, not a clean bill of health." + ] + errors = [ + f"KNOWN_ISSUES.md: issue #{number} is an open bug with no Bugs row" + for number in sorted(set(open_bugs) - set(rows)) + ] + errors += [ + f"KNOWN_ISSUES.md: the Bugs row for #{number} is not an open bug issue" + for number in sorted(set(rows) - set(open_bugs)) + ] + return errors + + +def open_bug_issues(repo: str = "aallan/vera") -> list[int]: + """Open issue numbers carrying the `bug` label, from the GitHub API.""" + numbers: list[int] = [] + for page in range(1, 11): + url = ( + f"https://api.github.com/repos/{repo}/issues" + f"?labels=bug&state=open&per_page=100&page={page}" + ) + request = Request(url, headers={"User-Agent": "vera-doc-counts/1"}) + token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") + if token: + request.add_header("Authorization", f"Bearer {token}") + # The URL is built from a caller-supplied repository, never from input. + with urlopen(request, timeout=30) as response: + payload = json.load(response) + if not payload: + break + numbers += [ + item["number"] for item in payload if "pull_request" not in item + ] + return numbers + + def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--check-bug-issues", + action="store_true", + help=( + "also check KNOWN_ISSUES.md's Bugs table against the open " + "`bug`-labelled issues (needs the GitHub API; for the release PR, " + "not for pre-commit)" + ), + ) + args = parser.parse_args() root = Path(__file__).resolve().parent.parent errors: list[str] = [] @@ -1155,40 +1466,18 @@ def check_testing(pattern: str, expected: int, label: str) -> None: readme_md = (root / "README.md").read_text(encoding="utf-8") - def check_readme(pattern: str, expected: int, label: str) -> None: - m = re.search(pattern, readme_md) - if not m: - return # Pattern absent from README is OK — not all counts appear - doc_val = int(m.group(1).replace(",", "")) - if doc_val != expected: - errors.append( - f"README.md {label}: doc says {doc_val}, live is {expected}" - ) - - check_readme( - r"([\d,]+) tests across", - live_total_tests, - "total tests", - ) - check_readme( - r"([\d,]+) tests, \d+% Python code coverage", - live_total_tests, - "project-status tests", - ) - check_readme( - r"tests across (\d+) files", - live_test_files, - "test file count", - ) - check_readme( - r"(\d+) programs across \d+ spec", - live_conformance, - "conformance programs", - ) - check_readme( - r"(\d+) end-to-end", - live_examples, - "example count", + # One sentence carries six live figures. Its four countable ones are + # gated together, each an error when it goes missing: the four patterns + # that used to sit here beside the tests one matched no README text at + # all, and returned silently rather than saying so. + errors.extend( + check_project_status( + readme_md, + live_total_tests, + live_conformance, + live_examples, + len(list((root / "spec").glob("*.md"))), + ) ) # ------------------------------------------------------------------ @@ -1449,6 +1738,33 @@ def check_readme(pattern: str, expected: int, label: str) -> None: errors.extend(check_corpus_count(root)) errors.extend(check_conformance_skip_total(root)) + # ------------------------------------------------------------------ + # 20. Check TESTING.md's dual-target row against a live run + # ------------------------------------------------------------------ + + split = dual_target_split(root) + if split is None: + errors.append( + f"TESTING.md: the dual-target differential ({_DUAL_TARGET_TEST}) " + f"could not be read — it failed, or it skipped for a reason the " + f"row's three documented properties do not cover" + ) + else: + errors.extend( + check_dual_target_row(testing_md, level_counts.get("run", 0), split) + ) + + # ------------------------------------------------------------------ + # 21. Check KNOWN_ISSUES.md's Bugs table + # ------------------------------------------------------------------ + + known_issues = (root / "KNOWN_ISSUES.md").read_text(encoding="utf-8") + errors.extend(check_bug_rows(known_issues)) + if args.check_bug_issues: + rows = bug_rows(known_issues) + if rows is not None: + errors.extend(check_bug_issue_parity(rows, open_bug_issues())) + # ------------------------------------------------------------------ # Report # ------------------------------------------------------------------ diff --git a/scripts/check_examples_run.py b/scripts/check_examples_run.py index 43debfc1..b9f96f91 100644 --- a/scripts/check_examples_run.py +++ b/scripts/check_examples_run.py @@ -47,10 +47,19 @@ fallback, which runs a different function and exits 0 — so every spec names its entry point and the CLI resolves it by name. An example that reaches outside the process answers a missing resource by printing a -message and completing normally — so the three that do carry an +message and completing normally — so the ones that do carry an ``expect`` substring only their success path prints, which is what makes deleting `examples/sqlitedb.sqlite` fail the gate instead of passing on the graceful in-memory arm. Both signals, per run, always. + +*Which* examples those are is derived rather than listed. +``check_sentinel_coverage`` reads each program's own declarations — the +`DB` effect in a function's effect row, the `IO.read_file` / +`IO.write_file` operations at a call site — and requires that set to +equal the set of specs carrying an ``expect``, in both directions. A +list of filenames would be a snapshot of today's corpus that says +nothing about the next database or filesystem example, which is the only +case the rule exists for. """ from __future__ import annotations @@ -86,6 +95,12 @@ class RunSpec(NamedTuple): deliberately not a full stdout pin; that belongs in the dedicated tests. + Which examples those are is not left to judgement: + ``check_sentinel_coverage`` derives the set from the resources each + program declares and holds it equal to the specs carrying an + ``expect``, so a missing sentinel and a spurious one are both + errors. + A ``NamedTuple`` rather than a frozen dataclass so the module can be loaded by the bare ``spec_from_file_location`` / ``exec_module`` recipe the sibling script tests use: ``@dataclass`` resolves its @@ -301,6 +316,221 @@ def check_coverage( return errors +# --------------------------------------------------------------------------- +# The derived sentinel rule +# --------------------------------------------------------------------------- + + +# The external resources an example can reach, as registry NAMES. Which +# examples must pin a sentinel follows from these by reading what each +# program declares, so one added tomorrow is covered by being written +# rather than by being remembered here — the case a list of filenames +# cannot cover, since it is a snapshot of the corpus it was written +# against. +# +# Both halves are needed because the effect row alone does not +# discriminate. `FileIO` and `Time` are not effects in Vera: file and +# clock operations live under `IO`, so `file_io.vera` declares exactly +# the bare `` that `hello_world.vera` does and only the operation it +# calls tells the two apart. Measured over the corpus, `DB` appears in +# `database.vera` and `sqlitedb.vera` alone, and `IO.read_file` / +# `IO.write_file` in `file_io.vera` alone. +RESOURCE_EFFECTS: tuple[str, ...] = ("DB",) +RESOURCE_OPS: tuple[tuple[str, str], ...] = ( + ("IO", "read_file"), + ("IO", "write_file"), +) + + +def resource_vocabulary() -> str: + """The declared resource names, for the messages that cite them.""" + return ", ".join( + [*RESOURCE_EFFECTS, *(f"{e}.{op}" for e, op in RESOURCE_OPS)] + ) + + +def resource_registry_errors() -> list[str]: + """Every declared resource name, checked against the live registry. + + A name the compiler no longer has would match no example, and with + nothing left requiring a sentinel the rule switches itself off while + still reporting success. Renaming the `DB` effect, or moving + `read_file` out of `IO`, must therefore fail here rather than + quietly empty the derivation — the same reason an empty corpus is an + error in ``check_coverage``. + + The `vera` import is lazy, as `check_doc_counts.check_homepage_facts` + does for the same registry: loading this module for its + classification tables should not drag in the compiler. + """ + from vera.introspect import builtin_effect_names, effects_payload + + live_effects = builtin_effect_names() + live_ops = { + str(item["name"]): {str(op) for op in item.get("ops", ())} + for item in effects_payload()["items"] + if item.get("kind") == "effect" + } + + errors: list[str] = [] + for name in RESOURCE_EFFECTS: + if name not in live_effects: + errors.append( + f"RESOURCE_EFFECTS names {name!r}, which the effect " + f"registry does not have — could not find it among " + f"{sorted(live_effects)}. Re-point it at the current " + f"name, so a renamed effect fails this gate instead of " + f"silently matching no example." + ) + for effect, op in RESOURCE_OPS: + if effect not in live_ops: + errors.append( + f"RESOURCE_OPS names {effect}.{op}, but the effect " + f"registry has no {effect!r} — could not find it among " + f"{sorted(live_ops)}. An operation is only meaningful " + f"under an effect that exists." + ) + elif op not in live_ops[effect]: + errors.append( + f"RESOURCE_OPS names {effect}.{op}, which {effect} does " + f"not have — could not find {op!r} among " + f"{sorted(live_ops[effect])}. Re-point it at the " + f"current operation, so a renamed one fails this gate " + f"instead of silently matching no example." + ) + return errors + + +def resource_signals(path: Path) -> frozenset[str]: + """The external-resource signals one example declares. + + Two sources, since neither alone discriminates: the resource effects + named in a function's effect row, and the resource operations the + source calls. Read off the parsed program rather than the text, so + a comment naming `` is prose and not a declaration — + `examples/sqlitedb.vera`'s first line is exactly such a comment, and + a text scan would agree with the parse there by luck while + disagreeing on the first example whose header describes what it + deliberately does not do. + + Whatever the parse raises propagates; ``check_sentinel_coverage`` + turns it into an error line, because an example whose signals are + *unknown* must not be spelled the same as one that has none. + """ + from vera import ast + from vera.obligations.cache import walk_nodes + from vera.parser import parse_to_ast + + program = parse_to_ast(path.read_text(encoding="utf-8"), file=str(path)) + effects = set(RESOURCE_EFFECTS) + ops = set(RESOURCE_OPS) + + signals: set[str] = set() + for node in walk_nodes(program): + if isinstance(node, ast.FnDecl): + # `walk_nodes` is a generic dataclass-field walk, so a + # `where` helper's row is reached alongside the outer one. + row = node.effect + if isinstance(row, ast.EffectSet): + for ref in row.effects: + # Unqualified refs only: `Mod.DB` names a user + # effect in another module, not the built-in the + # registry check validated. + if isinstance(ref, ast.EffectRef) and ref.name in effects: + signals.add(ref.name) + elif isinstance(node, ast.QualifiedCall): + if (node.qualifier, node.name) in ops: + signals.add(f"{node.qualifier}.{node.name}") + return frozenset(signals) + + +def check_sentinel_coverage( + examples_dir: Path, + run_specs: dict[str, RunSpec], +) -> list[str]: + """The examples that declare an external resource are exactly the + specs that carry an ``expect``. + + Both directions are errors. A resource-touching example with no + sentinel passes on its graceful arm the day its fixture vanishes, + which is the failure the sentinels exist to catch; a sentinel on an + example with no resource re-pins stdout that the dedicated output + tests own, and goes red on a cosmetic edit. + + An empty derived set is an error rather than a vacuous pass: with + nothing required the two sides agree however broken the derivation + is, which is the same failure mode ``check_coverage`` rules out for + a glob that stops matching. + """ + errors = resource_registry_errors() + if errors: + # Without a valid vocabulary the derivation below is + # meaningless — it would match nothing and then report every + # sentinel in the tables as spurious. + return errors + + signals_by_name: dict[str, frozenset[str]] = {} + for name in sorted(run_specs): + path = examples_dir / f"{name}.vera" + if not path.is_file(): + # `check_coverage` and `run_corpus` both report this, each + # naming the table the key came from; a third copy would + # only repeat them. It cannot hide the rule either — with + # the files gone the derivation is empty, which is the + # error below. + continue + try: + signals = resource_signals(path) + except Exception as exc: # noqa: BLE001 — unknown signals are reported, never read as none + errors.append( + f"{name}.vera could not be parsed, so what it reaches " + f"outside the process is unknown — read as 'declares no " + f"resource' it would drop out of this rule silently, " + f"and be diagnosed as carrying a sentinel for nothing: " + f"{exc}" + ) + continue + if signals: + signals_by_name[name] = signals + + if errors: + return errors + + if not signals_by_name: + return [ + f"no example in RUN_SPECS declares any of " + f"[{resource_vocabulary()}] — the derivation matched " + f"nothing, so the sentinel rule is no longer gated. This " + f"is an error rather than a pass: with the required set " + f"empty both sides of the rule agree however broken the " + f"derivation is, and every gate run reports success over " + f"zero examples." + ] + + pinned = {name for name, spec in run_specs.items() if spec.expect} + for name in sorted(set(signals_by_name) - pinned): + errors.append( + f"{name}.vera reaches outside the process " + f"({', '.join(sorted(signals_by_name[name]))}) but its " + f"RUN_SPECS entry sets no `expect` — a program like this " + f"answers a missing resource by printing a message and " + f"completing normally, so exit code alone cannot tell its " + f"success path from that arm. Pin a substring only the " + f"success path prints." + ) + for name in sorted(pinned - set(signals_by_name)): + errors.append( + f"{name}.vera declares no external resource " + f"([{resource_vocabulary()}]) but its RUN_SPECS entry pins " + f"the sentinel {run_specs[name].expect!r} — `expect` is for " + f"programs that answer a missing resource by completing " + f"normally. On any other example it re-pins stdout that " + f"the dedicated output tests own, and goes red on a " + f"cosmetic edit." + ) + return errors + + # --------------------------------------------------------------------------- # Invocation # --------------------------------------------------------------------------- @@ -576,6 +806,7 @@ def _block(title: str, errors: list[str], footer: str = "") -> list[str]: def error_blocks( coverage_errors: list[str], + sentinel_errors: list[str], doc_errors: list[str], failures: list[str], ) -> list[str]: @@ -588,6 +819,13 @@ def error_blocks( """ return [ *_block("COVERAGE ERRORS", coverage_errors), + *_block( + "SENTINEL COVERAGE", sentinel_errors, + "Which examples must pin a success sentinel is derived from " + "the resources each program declares, not from a list of " + "names. Fix the spec — or, if an example genuinely stopped " + "reaching outside the process, drop its `expect`.", + ), *_block( "DOCUMENTATION MISMATCH", doc_errors, "TESTING.md's execution-model table is the documented form of " @@ -628,10 +866,15 @@ def main() -> int: # The coverage rule gates the run: with the tables out of sync with # disk, a green run would be reporting on the wrong set of programs. if coverage_errors: - for line in error_blocks(coverage_errors, doc_errors, []): + for line in error_blocks(coverage_errors, [], doc_errors, []): print(line, file=sys.stderr) return 1 + # Derived from the examples themselves, so it runs only once the + # tables and disk agree: a spec whose file is missing is already + # reported above, and would otherwise be reported twice. + sentinel_errors = check_sentinel_coverage(examples_dir, RUN_SPECS) + with tempfile.TemporaryDirectory(prefix="vera-examples-run-") as td: failures = run_corpus(examples_dir, RUN_SPECS, Path(td)) @@ -645,7 +888,18 @@ def main() -> int: print(f" skip [{prop}]: {', '.join(skipped)}") print(f" {SKIP_PROPERTIES[prop]}") - blocks = error_blocks([], doc_errors, failures) + # With the rule holding, the specs carrying an `expect` ARE the + # derived set, so printing them names it — a reader of a gate run + # sees which examples the sentinel rule covers without opening this + # file, as the skip properties above already do for the skips. + if not sentinel_errors: + pinned = sorted(n for n, s in RUN_SPECS.items() if s.expect) + print( + f" sentinel required [{resource_vocabulary()}]: " + f"{', '.join(pinned)}" + ) + + blocks = error_blocks([], sentinel_errors, doc_errors, failures) if blocks: print("", file=sys.stderr) for line in blocks: diff --git a/scripts/check_grammar_alignment.py b/scripts/check_grammar_alignment.py index 8688ca59..5480fa56 100644 --- a/scripts/check_grammar_alignment.py +++ b/scripts/check_grammar_alignment.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 -"""Fail if a grammar rule name exists in the spec EBNF but not in the Lark -grammar, or the other way round. +"""Fail when ``spec/10-grammar.md`` and ``vera/grammar.lark`` stop describing +the same language: a rule name, a terminal, or a production body on one side +only. Background (#683): ``spec/10-grammar.md`` and ``vera/grammar.lark`` describe one language, and nothing held their rule names together. They drifted — the spec @@ -12,11 +13,17 @@ the ``with`` form of a handler clause was undocumented). Neither is a compiler bug; both mislead a reader who takes Chapter 10 as the map of the parse tree. -What this compares is **rule headers only**: a line of the form ``name:`` at the -start of a production, in either file. It is a name-level cross-check, not a -grammar equivalence check — two files can agree on every rule name and still -accept different languages. Rule *bodies* are not compared, so a drifted -right-hand side passes here. +The first comparison is **rule headers**: a line of the form ``name:`` at the +start of a production, in either file. That was the whole gate as #683 shipped +it, and it is not a grammar equivalence check — two files can agree on every +rule name and still accept different languages. Three further comparisons, +added for #1290, close the classes it could not see: terminals declared against +terminals referenced, within each file and in both directions; the pattern of +every regex-bodied terminal, across the two files; and the symbols each shared +production's right-hand side refers to. They live under their own banner +further down, with their own reasoning. What remains uncompared is the *shape* +of a right-hand side — alternation, grouping and repetition — so two +productions naming the same symbols in a different arrangement still pass. The two compared *sets* are deliberately blind to Lark's ``-> alias`` names. An alias renames the tree node an alternative produces; it is not a rule header, @@ -53,12 +60,15 @@ them, so the report never asks for two opposite edits at once. What the alias premise does *not* establish is that the Lark alternative still -spells the same construct as the spec production — that is a body-level fact -this header-only gate cannot see. It is worth being concrete about the weakest -two: ``tuple_literal`` and ``tuple_type`` rest on ``constructor_call`` and -``named_type``, general forms that would outlive tuples leaving the language -altogether. For those the premise catches the alternative being renamed or -moved to another rule, and nothing more. +spells the same construct as the spec production. It is worth being concrete +about the weakest two: ``tuple_literal`` and ``tuple_type`` rest on +``constructor_call`` and ``named_type``, general forms that would outlive +tuples leaving the language altogether. For those the premise catches the +alternative being renamed or moved to another rule, and nothing more. The body +comparison narrows that gap without closing it: a waiver naming ``lark_rule`` +is read there as "Lark inlines this production into that rule", so the symbols +the spec's production refers to are checked against the ones Lark's inlining +rule refers to. The allowlist is meant to stay small. If it needs to grow past a handful of entries, the header-only comparison has stopped being the right model and should @@ -109,8 +119,39 @@ def strip_comment(line: str) -> str: The single definition of what a comment is, shared by every scan of either file — a rule header, an alias, or anything added later. Commented-out grammar is deleted grammar; it must not satisfy a check. + + A ``//`` inside a quoted literal or a ``/…/`` regex body is not a comment. + A plain ``line.split("//")[0]`` truncated the annotation-comment terminal in + both files mid-pattern — ``%ignore /\\/\\*…\\*\\//`` ends in ``\\//`` — which + was harmless while only rule headers were scanned and silently wrong the + moment terminal bodies were (#1290). """ - return line.split("//")[0] + index = 0 + length = len(line) + while index < length: + char = line[index] + if char == "/" and line.startswith("//", index): + return line[:index] + if char in '"/': + end = _span_end(line, index, char) + if end is not None: + index = end + continue + index += 1 + return line + + +def _span_end(line: str, start: int, quote: str) -> int | None: + """Index just past the literal or regex opened at ``start``, or ``None``.""" + index = start + 1 + while index < len(line): + if line[index] == "\\": + index += 2 + continue + if line[index] == quote: + return index + 1 + index += 1 + return None # Names that appear on one side only, on purpose. Not drift; do not "fix". @@ -254,19 +295,394 @@ def drift( return actionable, stale, unsound +# --------------------------------------------------------------------------- +# Terminals and production bodies (#1290) +# +# The header comparison above is blind to three drift classes, each of which +# was demonstrated on a live file: a fabricated terminal added to §10.2 (the +# header pattern requires a lowercase lead, so no terminal is seen at all); a +# rule reference restored to a right-hand side; and a production body edited on +# one side only — the class most grammar edits actually fall into. The checks +# below close all three, and found two chapter defects beyond the two the issue +# named: `slot_ref`/`result_ref` admitting an arbitrary `type_expr` where the +# parser accepts only `UPPER_IDENT type_args?`, and a redundant `effect_list` +# alternative ambiguous with the one beside it. +# --------------------------------------------------------------------------- + +# A terminal declaration at the start of a line: an uppercase name, Lark's +# optional priority suffix, a colon, a body. +_TERMINAL_DECL = re.compile(r"^([A-Z][A-Z0-9_]*)(?:\.-?\d+)?[ \t]*:[ \t]*(\S.*?)[ \t]*$") +_TERMINAL_REF = re.compile(r"\b([A-Z][A-Z0-9_]*)\b") +_IGNORE_DECL = re.compile(r"^%ignore[ \t]+(\S.*?)[ \t]*$") +_QUOTED = re.compile(r'"((?:[^"\\]|\\.)*)"') +_BARE_REGEX = re.compile(r"^/(.+)/$") +_BARE_STRING = re.compile(r'^"((?:[^"\\]|\\.)*)"$') + +# The §10.2 sub-heading whose terminals the lexer throws away. Those are the +# only spec terminals allowed to go unreferenced by any production; the group +# is located by this marker rather than by a hand-list of names, so a renamed +# heading fails the gate instead of quietly widening it. +_SKIPPED_GROUP = "skipped" + + +def ebnf_fence_lines(text: str) -> list[str]: + """Every line inside a spec chapter's ```ebnf fences, fences excluded.""" + lines: list[str] = [] + in_fence = False + for line in text.splitlines(): + if line.lstrip().startswith("```"): + in_fence = line.lstrip().startswith("```ebnf") + continue + if in_fence: + lines.append(line) + return lines + + +def rule_bodies(lines: list[str]) -> dict[str, list[str]]: + """Map each rule header to its body lines, comments and aliases removed. + + A production spans its header line and the ``| …`` continuations under it, + exactly as ``extract_lark_aliases`` reads them. ``-> alias`` suffixes are + dropped: an alias names a tree node, never a symbol the production refers + to, and leaving them in makes every aliased alternative read as a reference + to a rule that does not exist. + """ + bodies: dict[str, list[str]] = {} + owner: str | None = None + for raw in lines: + line = strip_comment(raw) + header = _HEADER.match(line) + if header: + owner = header.group(2) + bodies.setdefault(owner, []).append(_ALIAS.sub("", line[header.end() :])) + continue + if owner is not None and _CONTINUATION.match(line): + bodies[owner].append(_ALIAS.sub("", line)) + continue + owner = None + return bodies + + +def terminal_declarations(lines: list[str]) -> dict[str, str]: + """Map each declared terminal name to its body.""" + declared: dict[str, str] = {} + for raw in lines: + match = _TERMINAL_DECL.match(strip_comment(raw)) + if match: + declared[match.group(1)] = match.group(2) + return declared + + +def ignore_patterns(lines: list[str]) -> list[str]: + """Bodies of Lark's ``%ignore`` directives — anonymous terminals.""" + return [ + match.group(1) + for match in (_IGNORE_DECL.match(strip_comment(raw)) for raw in lines) + if match + ] + + +def skipped_terminals(lines: list[str]) -> set[str]: + """Spec terminals declared under the ``(skipped)`` group heading. + + A *group* heading is a comment that opens a block — the first line of a + fence, or one following a blank line. A comment sitting between two + declarations is a note about the one below it, not a new group; reading + every comment as a heading ended the skipped group at the first such note + and reported two terminals the lexer discards as unused. + """ + names: set[str] = set() + in_group = False + at_block_start = True + for raw in lines: + stripped = raw.strip() + if not stripped: + at_block_start = True + continue + if stripped.startswith("//"): + if at_block_start: + in_group = _SKIPPED_GROUP in stripped.lower() + at_block_start = False + continue + at_block_start = False + match = _TERMINAL_DECL.match(strip_comment(raw)) + if match and in_group: + names.add(match.group(1)) + return names + + +def normalise_pattern(body: str) -> str: + """A regex body with Lark's delimiter and quote escapes removed. + + ``\\/`` and ``\\"`` mean exactly ``/`` and ``"`` to a regex engine; the two + files escape them differently and nothing else, so this is the whole of the + difference between ``STRING_LIT`` and ``ANNOTATION_COMMENT`` as the two + files spell them. ``\\\\`` is copied through, so an escaped backslash is + never mistaken for an escape of the character after it. + """ + out: list[str] = [] + index = 0 + while index < len(body): + if body[index] == "\\" and index + 1 < len(body): + following = body[index + 1] + out.append(following if following in '/"' else body[index : index + 2]) + index += 2 + continue + out.append(body[index]) + index += 1 + return "".join(out) + + +def _referenced_terminals(bodies: dict[str, list[str]]) -> set[str]: + return { + name + for lines in bodies.values() + for line in lines + for name in _TERMINAL_REF.findall(_QUOTED.sub(" ", line)) + } + + +def terminal_audit( + lark_lines: list[str], spec_lines: list[str] +) -> list[str]: + """Declared-versus-referenced, within each file and in both directions. + + A terminal nothing refers to is dead weight the reader has to reconcile — + ``SOME``/``NONE``/``OK``/``ERR``/``COLON`` sat in the Lark grammar that way + while the constructors they claimed to lex went through ``UPPER_IDENT``. A + terminal referred to and never declared is the opposite failure and Lark + had one of those too, ``DOUBLE_COLON`` in ``module_call``. Neither + direction was checked anywhere. + """ + problems: list[str] = [] + for label, lines, allow_unreferenced in ( + (LARK, lark_lines, set[str]()), + (SPEC, spec_lines, skipped_terminals(spec_lines)), + ): + declared = set(terminal_declarations(lines)) + referenced = _referenced_terminals(rule_bodies(lines)) + if label == SPEC and not allow_unreferenced: + problems.append( + f"{label}: no terminal group marked `({_SKIPPED_GROUP})` was " + f"found, so every declared terminal would have to be referenced" + ) + for name in sorted(declared - referenced - allow_unreferenced): + problems.append(f"{label}: terminal {name} is declared and never used") + for name in sorted(referenced - declared): + problems.append(f"{label}: terminal {name} is used and never declared") + return problems + + +def terminal_patterns(lark_lines: list[str], spec_lines: list[str]) -> list[str]: + """Every pattern-bearing terminal must be spelled the same in both files. + + Only terminals whose body is a bare ``/regex/`` are compared: the spec + names each keyword and punctuation mark that Lark writes as an inline + quoted literal, and those have no Lark declaration to compare against. The + regex-bodied ones do, either as a named terminal or as an ``%ignore``, and + ``BLOCK_COMMENT`` was the one that had neither — the spec published a + non-nesting ``/\\{-[\\s\\S]*?-\\}/`` for a construct §1.3 says nests and + ``vera/lexical.py`` resolves by counting depth. + """ + lark_declared = terminal_declarations(lark_lines) + spec_declared = terminal_declarations(spec_lines) + lark_patterns = { + normalise_pattern(match.group(1)) + for match in ( + _BARE_REGEX.match(body) + for body in [*lark_declared.values(), *ignore_patterns(lark_lines)] + ) + if match + } + problems: list[str] = [] + for name, body in sorted(spec_declared.items()): + regex = _BARE_REGEX.match(body) + if regex is None: + continue + if normalise_pattern(regex.group(1)) not in lark_patterns: + problems.append( + f"{SPEC}: terminal {name} publishes a pattern {LARK} does not " + f"have, as a terminal or an %ignore: {body}" + ) + for name, body in sorted(lark_declared.items()): + if name not in spec_declared: + problems.append(f"{SPEC}: terminal {name} is declared only in {LARK}") + elif normalise_pattern(body) != normalise_pattern(spec_declared[name]): + problems.append( + f"{name}: {LARK} has {body}, {SPEC} has {spec_declared[name]}" + ) + return problems + + +def _literal_terminals(spec_lines: list[str]) -> tuple[dict[str, str], list[str]]: + """Map each quoted literal the spec names to its terminal, plus clashes.""" + table: dict[str, str] = {} + clashes: list[str] = [] + for name, body in sorted(terminal_declarations(spec_lines).items()): + match = _BARE_STRING.match(body) + if match is None: + continue + literal = match.group(1) + if literal in table: + clashes.append( + f"{SPEC}: terminals {table[literal]} and {name} both spell {body}" + ) + continue + table[literal] = name + return table, clashes + + +def _symbols(line: str, rules: set[str]) -> tuple[set[str], set[str]]: + """``(rule references, terminal references)`` in one production body line.""" + return ( + {name for name in re.findall(r"\b[a-z][a-z0-9_]*\b", line)} & rules, + set(_TERMINAL_REF.findall(_QUOTED.sub(" ", line))), + ) + + +def _lark_symbols( + rule: str, + bodies: dict[str, list[str]], + rules: set[str], + literals: dict[str, str], +) -> tuple[set[str], set[str], list[str]]: + referenced: set[str] = set() + terminals: set[str] = set() + unmapped: list[str] = [] + for line in bodies[rule]: + rule_refs, terminal_refs = _symbols(line, rules) + referenced |= rule_refs + terminals |= terminal_refs + for raw in _QUOTED.findall(line): + literal = raw.replace('\\"', '"') + if literal in literals: + terminals.add(literals[literal]) + else: + unmapped.append(literal) + return referenced - {rule}, terminals, unmapped + + +def _spec_symbols( + rule: str, bodies: dict[str, list[str]], rules: set[str] +) -> tuple[set[str], set[str], set[str]]: + """``(rules, terminals, inlined)`` for one spec production. + + A waiver saying "Lark expresses this as an alternative of ``lark_rule``" + fixes where the spec's separate production corresponds on the Lark side: + seen from any other rule it *is* ``lark_rule``, and seen from ``lark_rule`` + itself its body is inlined there. Reading the waiver that way is what lets + the body comparison run against the shipped files with no waivers of its + own. + + ``inlined`` names the symbols that arrived by that folding rather than from + the production's own text. Inlining moves a symbol across a rule boundary + and a one-level set comparison cannot say how far it moved — the spec's + ``tuple_type`` contributes ``LT``/``COMMA``/``GT`` that Lark keeps one rule + deeper, inside ``type_args`` — so a folded symbol missing on the Lark side + is not reported. The other direction still is: a symbol Lark refers to and + the chapter does not is drift however the waiver reads. + """ + waived = {name for name, entry in ALLOWLIST.items() if entry.side == "spec"} + referenced: set[str] = set() + terminals: set[str] = set() + for line in bodies[rule]: + rule_refs, terminal_refs = _symbols(line, rules) + referenced |= rule_refs + terminals |= terminal_refs + folded: set[str] = set() + inlined: set[str] = set() + for name in sorted(referenced): + waiver = ALLOWLIST.get(name) if name in waived else None + if waiver is None: + folded.add(name) + elif waiver.lark_rule is None: + continue + elif waiver.lark_rule != rule: + folded.add(waiver.lark_rule) + elif name in bodies: + inner_rules, inner_terminals, _ = _spec_symbols(name, bodies, rules) + folded |= inner_rules + terminals |= inner_terminals + inlined |= inner_rules | inner_terminals + return (folded - waived) - {rule}, terminals, inlined + + +def body_drift(lark_lines: list[str], spec_lines: list[str]) -> list[str]: + """Compare the symbols each shared production refers to. + + Rule references and terminal references, per production, for every rule + both files declare. A rule's reference to *itself* is excluded: Lark + spells repetition with left recursion and the chapter spells it with a + Kleene star, so the eight operator-precedence rules differ there by + notation and not by language. + """ + lark_bodies = rule_bodies(lark_lines) + spec_bodies = rule_bodies(spec_lines) + literals, problems = _literal_terminals(spec_lines) + waived = {name for name, entry in ALLOWLIST.items() if entry.side == "spec"} + shared = sorted(set(lark_bodies) & set(spec_bodies)) + if not shared: + return [*problems, "no rule is a production in both files"] + for rule in shared: + lark_rules, lark_terms, unmapped = _lark_symbols( + rule, lark_bodies, set(lark_bodies), literals + ) + spec_rules, spec_terms, inlined = _spec_symbols( + rule, spec_bodies, set(spec_bodies) + ) + for literal in sorted(set(unmapped)): + problems.append( + f'{rule}: {LARK} matches the literal "{literal}" and no {SPEC} ' + f"terminal declares it" + ) + for label, only_lark, only_spec in ( + ( + "rule", + lark_rules - waived - spec_rules, + spec_rules - lark_rules - inlined, + ), + ("terminal", lark_terms - spec_terms, spec_terms - lark_terms - inlined), + ): + for name in sorted(only_lark): + problems.append(f"{rule}: refers to {label} {name} only in {LARK}") + for name in sorted(only_spec): + problems.append(f"{rule}: refers to {label} {name} only in {SPEC}") + return problems + + +def _report(title: str, problems: list[str], remedy: str) -> None: + print(f"\nERROR: {title}:", file=sys.stderr) + for problem in problems: + print(f" {problem}", file=sys.stderr) + print(f"\n{remedy}", file=sys.stderr) + + def main() -> int: root = Path(__file__).resolve().parent.parent lark = extract_lark_rules(root / LARK) spec = extract_spec_rules(root / SPEC) + lark_text = (root / LARK).read_text(encoding="utf-8") + lark_lines = lark_text.splitlines() + spec_lines = ebnf_fence_lines((root / SPEC).read_text(encoding="utf-8")) + differing = lark ^ spec - actionable, stale, unsound = drift( - lark, spec, (root / LARK).read_text(encoding="utf-8") - ) + actionable, stale, unsound = drift(lark, spec, lark_text) + unused = terminal_audit(lark_lines, spec_lines) + patterns = terminal_patterns(lark_lines, spec_lines) + bodies = body_drift(lark_lines, spec_lines) print(f" {len(lark)} rule headers in {LARK}") print(f" {len(spec)} rule headers in {SPEC}") print(f" {len(differing)} differ, {len(ALLOWLIST)} allowlisted") + print( + f" {len(terminal_declarations(lark_lines))} terminals in {LARK}, " + f"{len(terminal_declarations(spec_lines))} in {SPEC}" + ) + print( + f" {len(set(rule_bodies(lark_lines)) & set(rule_bodies(spec_lines)))} " + f"production bodies compared" + ) if actionable: print("\nERROR: grammar rule names have drifted:", file=sys.stderr) @@ -306,10 +722,34 @@ def main() -> int: "files and delete the entry.", file=sys.stderr, ) - if actionable or stale or unsound: + if unused: + _report( + "terminals declared without a use, or used without a declaration", + unused, + "Delete the terminal, or add the production that refers to it. A " + "terminal nothing refers to is not part of the language.", + ) + if patterns: + _report( + "terminal patterns differ between the two files", + patterns, + f"Make {SPEC} publish the pattern the parser actually has. Where a " + f"construct is not regular — nested block comments are the standing " + f"case — say so in the chapter rather than publishing a regex that " + f"accepts a different language.", + ) + if bodies: + _report( + "production bodies refer to different symbols", + bodies, + f"Bring the two right-hand sides together. If {SPEC} is the one " + f"that is wrong, fix the chapter: it is read as the map of the " + f"parse tree.", + ) + if actionable or stale or unsound or unused or patterns or bodies: return 1 - print(f"OK: {LARK} and {SPEC} agree on every rule name.") + print(f"OK: {LARK} and {SPEC} agree on every rule name, terminal and body.") return 0 diff --git a/scripts/release.py b/scripts/release.py index f7f3e3d7..b4f42437 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -26,6 +26,7 @@ ROOT = Path(__file__).resolve().parent.parent PROJECT = "veralang" +REPOSITORY = "aallan/vera" INDEX_JSON_URLS = { "pypi": f"https://pypi.org/pypi/{PROJECT}/json", "testpypi": f"https://test.pypi.org/pypi/{PROJECT}/json", @@ -33,6 +34,17 @@ PACKAGE_AFFECTING_PATHS = ("LICENSE", "PYPI_README.md", "pyproject.toml", "vera") _VERSION_RE = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") +# GitHub refuses a release body over 125,000 characters with HTTP 422, and the +# release workflow reaches that step AFTER the immutable PyPI upload and AFTER +# the tag is cut (#1288). The builder is therefore total: oversized notes are +# condensed rather than allowed to fail the step. +GITHUB_RELEASE_BODY_LIMIT = 125_000 +RELEASE_BODY_BUDGET = 120_000 +_SECTION_HEADING_RE = re.compile(r"^### .+$") +_BULLET_LEAD_RE = re.compile(r"^- \*\*(?P.+?)\*\*") +_BULLET_RE = re.compile(r"^-\s+(?P\S.*)$") +_ISSUE_LINK_RE = re.compile(r"\[#\d+\]\(https://github\.com/[^\s)]+\)") + class ReleaseError(ValueError): """A release invariant was not satisfied.""" @@ -99,11 +111,20 @@ def version_at_ref(ref: str, root: Path = ROOT) -> str: return version -def changelog_notes(text: str, version: str) -> str: - """Extract a non-empty, bullet-bearing release section.""" +@dataclass(frozen=True) +class ChangelogSection: + """One release section of ``CHANGELOG.md``, with its heading date.""" + + version: str + date: str | None + notes: str + + +def changelog_section(text: str, version: str) -> ChangelogSection: + """Extract a non-empty, bullet-bearing release section and its date.""" parse_version(version) heading = re.compile( - rf"^## \[{re.escape(version)}\](?: - [0-9]{{4}}-[0-9]{{2}}-[0-9]{{2}})?\s*$", + rf"^## \[{re.escape(version)}\](?: - (?P[0-9]{{4}}-[0-9]{{2}}-[0-9]{{2}}))?\s*$", re.MULTILINE, ) match = heading.search(text) @@ -116,12 +137,123 @@ def changelog_notes(text: str, version: str) -> str: raise ReleaseError( f"CHANGELOG.md section [{version}] must contain at least one bullet" ) - return notes + return ChangelogSection(version, match.group("date"), notes) + + +def changelog_notes(text: str, version: str) -> str: + """Extract a non-empty, bullet-bearing release section.""" + return changelog_section(text, version).notes + + +def section_for_version(version: str, root: Path = ROOT) -> ChangelogSection: + """Read one release section from the checkout's changelog.""" + return changelog_section( + (root / "CHANGELOG.md").read_text(encoding="utf-8"), version + ) def notes_for_version(version: str, root: Path = ROOT) -> str: """Extract release notes from the checkout's changelog.""" - return changelog_notes((root / "CHANGELOG.md").read_text(encoding="utf-8"), version) + return section_for_version(version, root).notes + + +def changelog_anchor(version: str, date: str | None) -> str: + """Return GitHub's heading anchor for a ``## [version] - date`` line.""" + heading = f"[{version}]" + (f" - {date}" if date else "") + slug = "".join( + character + for character in heading.lower() + if character.isalnum() or character in "- " + ) + return "#" + slug.replace(" ", "-") + + +def _index_line(bullet: str) -> str: + """Condense one CHANGELOG bullet to its headline-index line. + + The lead-in is the bullet's bold run, de-emphasised, and the reference is + the bullet's LAST issue or pull-request link — the rule that reproduces + the v0.1.10 manual recovery, whose attribution for at least one bullet sat + mid-prose rather than immediately after the bold run. A bullet with no + bold run keeps its own text, so no bullet is ever dropped from the index. + """ + lead_match = _BULLET_LEAD_RE.match(bullet) + if lead_match is not None: + lead = lead_match.group("lead") + else: + plain = _BULLET_RE.match(bullet) + if plain is None: # pragma: no cover - callers filter on _BULLET_RE + raise ReleaseError(f"not a changelog bullet: {bullet!r}") + lead = plain.group("text") + links = _ISSUE_LINK_RE.findall(bullet) + return f"- {lead} ({links[-1]})" if links else f"- {lead}" + + +def condense_notes( + section: ChangelogSection, + *, + repo: str = REPOSITORY, + limit: int = GITHUB_RELEASE_BODY_LIMIT, +) -> str: + """Rewrite a release section as the headline index plus a CHANGELOG link. + + The shape is the one the v0.1.10 release was completed by hand with: the + section's ``###`` subsection headers, one condensed line per bullet, and a + link to the canonical section at the tag — the CHANGELOG being the release + notes of record either way. + """ + anchor = changelog_anchor(section.version, section.date) + dated = f"[{section.version}]" + (f" - {section.date}" if section.date else "") + preamble = ( + f"The full release notes for this version are {len(section.notes):,} " + f"characters — past GitHub's {limit:,}-character release-body limit — so " + "this body carries the headline index and the canonical notes live in the " + f"CHANGELOG at the tag: **[CHANGELOG.md § {dated}]" + f"(https://github.com/{repo}/blob/v{section.version}/CHANGELOG.md{anchor})**" + ) + + lines: list[str] = [] + bullets = 0 + for line in section.notes.splitlines(): + if _SECTION_HEADING_RE.match(line): + lines.append("") + lines.append(line) + elif _BULLET_RE.match(line): + lines.append(_index_line(line)) + bullets += 1 + if not bullets: + raise ReleaseError( + f"release section [{section.version}] condensed to no bullets" + ) + return preamble + "\n" + "\n".join(lines).rstrip() + "\n" + + +def release_body( + section: ChangelogSection, + *, + repo: str = REPOSITORY, + budget: int = RELEASE_BODY_BUDGET, + limit: int = GITHUB_RELEASE_BODY_LIMIT, +) -> str: + """Return a release body that always fits GitHub's limit (#1288). + + Within budget the section is published verbatim. Past it the section is + condensed, and in the pathological case where even the index overflows the + index is truncated — the step must never be the thing that fails after the + immutable archives are already on PyPI. + """ + if len(section.notes) <= budget: + return section.notes + condensed = condense_notes(section, repo=repo, limit=limit) + if len(condensed) <= limit: + return condensed + notice = ( + f"\n\n_This index is truncated at {limit:,} characters; " + "the CHANGELOG link above carries every entry._\n" + ) + kept = condensed[: limit - len(notice)] + cut = kept.rfind("\n") + return (kept[:cut] if cut > 0 else kept.rstrip()) + notice def validate_version_sync(root: Path = ROOT) -> None: @@ -365,9 +497,10 @@ def _parser() -> argparse.ArgumentParser: prepare.add_argument("--confirm-version") prepare.add_argument("--github-output", type=Path, required=True) - notes = commands.add_parser("notes", help="extract a changelog release section") + notes = commands.add_parser("notes", help="build a release body that fits") notes.add_argument("--version", required=True) notes.add_argument("--output", type=Path, required=True) + notes.add_argument("--repo", default=REPOSITORY) manifest = commands.add_parser("manifest", help="write archive SHA-256 values") manifest.add_argument("--dist-dir", type=Path, default=Path("dist")) @@ -398,10 +531,18 @@ def main(argv: list[str] | None = None) -> int: action = f"publish to {plan.target}" if plan.publish else "no release" print(f"Release plan for {plan.version}: {action}.") elif args.command == "notes": + section = section_for_version(args.version) + body = release_body(section, repo=args.repo) args.output.parent.mkdir(parents=True, exist_ok=True) - args.output.write_text( - notes_for_version(args.version) + "\n", encoding="utf-8" - ) + args.output.write_text(body.rstrip("\n") + "\n", encoding="utf-8") + # A pass-through returns the section itself, so length is the + # signal a reader can check against the printed numbers. + if len(body) != len(section.notes): + print( + f"Release notes for {args.version} condensed from " + f"{len(section.notes):,} to {len(body):,} characters " + f"(GitHub's limit is {GITHUB_RELEASE_BODY_LIMIT:,})." + ) elif args.command == "manifest": write_manifest(args.dist_dir, args.output) elif args.command == "assert-absent": diff --git a/spec/10-grammar.md b/spec/10-grammar.md index 4fdde43c..6b6ffc86 100644 --- a/spec/10-grammar.md +++ b/spec/10-grammar.md @@ -21,7 +21,10 @@ Conventions: // Whitespace and comments (skipped) WS: /\s+/ LINE_COMMENT: /--[^\n]*/ -BLOCK_COMMENT: /\{-[\s\S]*?-\}/ +// Block comments nest (Section 1.3), so they are not a regular language and +// have no regex form. The reference implementation removes them in +// vera/lexical.py, by counting depth, before the parser sees the text. +BLOCK_COMMENT: "{-" (BLOCK_COMMENT | /[\s\S]/)* "-}" ANNOTATION_COMMENT: /\/\*[^*]*\*+([^/*][^*]*\*+)*\// // Keywords @@ -94,6 +97,7 @@ SEMICOLON: ";" DOUBLE_COLON: "::" BAR: "|" UNDERSCORE: "_" +HOLE: "?" // Literals INT_LIT: /0|[1-9][0-9]*/ @@ -182,7 +186,6 @@ pure_effect: PURE effect_set: LT effect_list GT effect_list: effect_ref (COMMA effect_ref)* - | UPPER_IDENT // effect variable effect_ref: UPPER_IDENT type_args? | UPPER_IDENT DOT UPPER_IDENT type_args? // qualified effect @@ -281,6 +284,7 @@ primary_expr: INT_LIT | TRUE | FALSE | LPAREN RPAREN // unit literal + | HOLE // typed hole ? (Section 4.17) | slot_ref // @T.n | result_ref // @T.result | fn_call // function/constructor application @@ -303,9 +307,9 @@ primary_expr: INT_LIT ### 10.3.9 Slot References ```ebnf -slot_ref: AT type_expr DOT INT_LIT +slot_ref: AT UPPER_IDENT type_args? DOT INT_LIT -result_ref: AT type_expr DOT RESULT +result_ref: AT UPPER_IDENT type_args? DOT RESULT ``` ### 10.3.10 Function Calls and Constructors diff --git a/tests/test_check_corpus_differential.py b/tests/test_check_corpus_differential.py new file mode 100644 index 00000000..df46730a --- /dev/null +++ b/tests/test_check_corpus_differential.py @@ -0,0 +1,498 @@ +"""Tests for scripts/check_corpus_differential.py — the burndown +instrument that compiles the corpus at two revisions and reports which +programs moved. + +The differential itself is far too slow to run from a test: it compiles +every corpus program twice, once per revision, in its own subprocess. +What is tested here is everything *around* those two compiles — the +corpus enumeration, the four-way mover classification, the comparison +and its counts, the report, the exit code, and the ``--json`` shape — +with every compile result injected. + +Injection is not only a speed measure. The one-sided-failure cases +(``compiles only at HEAD`` / ``compiles only at ``) need a +revision pair where a program's compilability *changed*, and the shipped +corpus deliberately has no such pair: at any two revisions CI has passed +on, the same programs compile. Those two cases are exactly the class +the PR #1323 record called out as mis-described, so they are reachable +on demand here rather than left to a lucky revision. + +Two conventions are inherited from ``tests/test_check_examples_run.py`` +and asserted throughout: an enumeration that matches nothing must be an +ERROR rather than a silent pass (otherwise a moved corpus root switches +the instrument off while it still reports success), and each check is +exercised in both directions — a classification that can only ever +answer "identical" would otherwise report a green differential over a +compiler that moved under it. +""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +from typing import Any + +_SCRIPT = ( + Path(__file__).parent.parent / "scripts" / "check_corpus_differential.py" +) +_ROOT = Path(__file__).parent.parent + + +def _load() -> Any: + spec = importlib.util.spec_from_file_location( + "check_corpus_differential", _SCRIPT + ) + assert spec is not None + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +_MOD = _load() + + +# --------------------------------------------------------------------------- +# Injected compile results +# --------------------------------------------------------------------------- + + +def _ok(digest: str, size: int = 512) -> Any: + """A program that compiled, with the given WAT digest.""" + return _MOD.Artifact(ok=True, digest=digest, size=size, error="") + + +def _failed(error: str = "[E101] type mismatch") -> Any: + """A program that did not compile, with the reason the CLI gave.""" + return _MOD.Artifact(ok=False, digest=None, size=0, error=error) + + +def _info() -> Any: + return _MOD.RunInfo( + base_ref="origin/main", + base_sha="0123456789abcdef", + base_root="/scratch/vera-base-0123456789ab", + head_root="/repo", + ) + + +# --------------------------------------------------------------------------- +# Corpus enumeration +# --------------------------------------------------------------------------- + + +class TestCorpusEnumeration: + """What gets compiled, and the refusal to compile nothing.""" + + def test_the_real_corpus_spans_both_roots_and_recurses(self) -> None: + """The corpus is `examples/` plus `tests/conformance/`, at any + depth. The nested `examples/vera/` and `tests/conformance/vera/` + modules are corpus too — `examples/modules.vera` is built from + them, so a non-recursive glob would compare a program whose + inputs the differential never looked at (the same gap + `scripts/check_corpus_canonical.py` records having had).""" + files = _MOD.corpus_files(_ROOT) + rel = {p.relative_to(_ROOT).as_posix() for p in files} + + assert len(rel) > 100 + assert any(r.startswith("examples/") for r in rel) + assert any(r.startswith("tests/conformance/") for r in rel) + assert "examples/vera/math.vera" in rel + assert "tests/conformance/vera/util.vera" in rel + + def test_an_empty_corpus_is_an_error_not_a_skip(self) -> None: + """A differential over zero programs finds zero movers and would + report that as success. The enumeration matching nothing must + fail the run instead.""" + message = _MOD.corpus_guard([], Path("/nowhere")) + assert message is not None + assert "could not find" in message + + def test_a_populated_corpus_passes_the_guard(self) -> None: + """The other direction: the guard must not fail every run.""" + assert _MOD.corpus_guard([Path("/repo/examples/a.vera")], + Path("/repo")) is None + + +# --------------------------------------------------------------------------- +# Mover classification — the four cases +# --------------------------------------------------------------------------- + + +class TestMoverClassification: + """One program, two revisions, four outcomes. + + Both directions of the failure axis are separate cells, and named + separately: a classifier that lumps them together reports a program + that *stopped* compiling as one that *started*, which is the + mis-description the PR #1323 record names. + """ + + def test_identical_wat_is_not_a_mover(self) -> None: + assert _MOD.classify(_ok("abc"), _ok("abc"), "origin/main") is None + + def test_differing_wat_is_a_mover(self) -> None: + verdict = _MOD.classify(_ok("abc"), _ok("def"), "origin/main") + assert verdict is not None + kind, reason = verdict + assert kind == "wat-differs" + assert "WAT differs" in reason + + def test_compiling_only_at_head_is_a_mover(self) -> None: + """Base failed, HEAD succeeded — the working tree made a program + compilable, which is a move even though no WAT can be compared.""" + verdict = _MOD.classify(_failed("[E101] boom"), _ok("abc"), + "origin/main") + assert verdict is not None + kind, reason = verdict + assert kind == "head-only" + assert "compiles only at HEAD" in reason + assert "[E101] boom" in reason + + def test_compiling_only_at_base_is_a_mover(self) -> None: + """The reverse direction, and the one that matters most: the + working tree BROKE a program that used to compile. The reason + must name the base revision, not HEAD.""" + verdict = _MOD.classify(_ok("abc"), _failed("[E620] dropped"), + "origin/main") + assert verdict is not None + kind, reason = verdict + assert kind == "base-only" + assert "compiles only at origin/main" in reason + assert "[E620] dropped" in reason + + def test_failing_at_both_revisions_is_not_a_mover(self) -> None: + """The negative conformance fixtures live here: they fail to + compile at every revision, so they are not movers. They are + counted separately rather than folded into `identical`, because + a run whose corpus was entirely uncompilable would otherwise + report a wall of agreement it never measured.""" + assert _MOD.classify(_failed(), _failed("other"), + "origin/main") is None + + +# --------------------------------------------------------------------------- +# The comparison +# --------------------------------------------------------------------------- + + +class TestComparison: + """The per-program verdicts, rolled up.""" + + def _mixed(self) -> Any: + base = { + "a.vera": _ok("same"), + "b.vera": _ok("same"), + "c.vera": _ok("old"), + "d.vera": _failed("[E101] base"), + "e.vera": _ok("gone"), + "f.vera": _failed("[E101] base"), + } + head = { + "a.vera": _ok("same"), + "b.vera": _ok("same"), + "c.vera": _ok("new"), + "d.vera": _ok("added"), + "e.vera": _failed("[E101] head"), + "f.vera": _failed("[E101] head"), + } + return _MOD.compare(base, head, "origin/main") + + def test_counts_partition_the_corpus(self) -> None: + c = self._mixed() + assert c.compared == 6 + assert c.identical == 2 + assert c.both_failed == 1 + assert len(c.movers) == 3 + assert c.compared == c.identical + c.both_failed + len(c.movers) + + def test_each_mover_keeps_its_own_kind(self) -> None: + """By position, not by count: three movers of three different + kinds are exactly the case a classifier that mislabels one of + them still gets the count right on.""" + c = self._mixed() + assert {m.path: m.kind for m in c.movers} == { + "c.vera": "wat-differs", + "d.vera": "head-only", + "e.vera": "base-only", + } + + def test_movers_are_reported_in_path_order(self) -> None: + c = self._mixed() + assert [m.path for m in c.movers] == sorted(m.path for m in c.movers) + + def test_a_program_missing_from_one_side_is_reported_not_ignored( + self, + ) -> None: + """A program the base side never reported on cannot be compared. + Dropping it silently would shrink the corpus mid-run and still + print a clean verdict; it is surfaced instead, and it is not + counted as agreement.""" + c = _MOD.compare( + {"a.vera": _ok("same")}, + {"a.vera": _ok("same"), "b.vera": _ok("x")}, + "origin/main", + ) + assert c.unreported == ["b.vera"] + assert c.identical == 1 + assert c.compared == 1 + + +# --------------------------------------------------------------------------- +# Report, exit code, JSON +# --------------------------------------------------------------------------- + + +class TestReportAndExitCode: + """What a run prints, and what it exits.""" + + def _clean(self) -> Any: + return _MOD.compare( + {"a.vera": _ok("same"), "b.vera": _failed()}, + {"a.vera": _ok("same"), "b.vera": _failed()}, + "origin/main", + ) + + def _moved(self) -> Any: + return _MOD.compare( + {"a.vera": _ok("old"), "b.vera": _ok("kept")}, + {"a.vera": _ok("new"), "b.vera": _failed("[E620] dropped")}, + "origin/main", + ) + + def test_a_clean_run_exits_zero_and_says_so(self, capsys: Any) -> None: + assert _MOD.emit(_info(), self._clean(), as_json=False) == 0 + out = capsys.readouterr().out + assert "No movers" in out + + def test_a_run_with_movers_exits_one(self, capsys: Any) -> None: + assert _MOD.emit(_info(), self._moved(), as_json=False) == 1 + capsys.readouterr() + + def test_every_mover_is_named_with_its_reason(self, capsys: Any) -> None: + _MOD.emit(_info(), self._moved(), as_json=False) + captured = capsys.readouterr() + report = captured.out + captured.err + assert "a.vera" in report + assert "WAT differs" in report + assert "b.vera" in report + assert "compiles only at origin/main" in report + + def test_the_both_failed_count_is_reported_not_hidden( + self, capsys: Any + ) -> None: + """A corpus where half the programs compile at neither revision + agrees vacuously. The count is printed so a reader of a green + run knows how much of it was measured.""" + _MOD.emit(_info(), self._clean(), as_json=False) + out = capsys.readouterr().out + assert "1" in out + assert "neither revision" in out + + def test_an_unreported_program_exits_one(self, capsys: Any) -> None: + """A truncated run is a failed run, not a clean one — even with + no movers among the programs that did report.""" + comparison = _MOD.compare( + {"a.vera": _ok("same")}, + {"a.vera": _ok("same"), "b.vera": _ok("x")}, + "origin/main", + ) + assert _MOD.emit(_info(), comparison, as_json=False) == 1 + capsys.readouterr() + + def test_json_carries_the_verdict_and_the_run_identity( + self, capsys: Any + ) -> None: + code = _MOD.emit(_info(), self._moved(), as_json=True) + payload = json.loads(capsys.readouterr().out) + + assert code == 1 + assert payload["ok"] is False + assert payload["base_ref"] == "origin/main" + assert payload["base_sha"] == "0123456789abcdef" + assert payload["base_root"] == "/scratch/vera-base-0123456789ab" + assert payload["head_root"] == "/repo" + assert payload["compared"] == 2 + assert payload["identical"] == 0 + assert payload["both_failed"] == 0 + assert payload["unreported"] == [] + assert {m["path"]: m["kind"] for m in payload["movers"]} == { + "a.vera": "wat-differs", + "b.vera": "base-only", + } + assert all("reason" in m for m in payload["movers"]) + + def test_json_flags_a_clean_run_ok(self, capsys: Any) -> None: + code = _MOD.emit(_info(), self._clean(), as_json=True) + payload = json.loads(capsys.readouterr().out) + assert code == 0 + assert payload["ok"] is True + assert payload["movers"] == [] + assert payload["both_failed"] == 1 + + +# --------------------------------------------------------------------------- +# The compiler canary +# --------------------------------------------------------------------------- + + +class TestCompilerCanary: + """Which `vera` each side actually imported. + + The load-bearing guard of the whole instrument. Both sides run the + same CLI under different `PYTHONPATH`s, and the venv carries an + editable install of a *third* checkout whose finder sits on + `sys.meta_path`. If either side resolves `vera` somewhere other + than its own root, the run compares a revision against itself and + reports 0 movers — a green verdict that measured nothing. + """ + + def test_a_compiler_under_the_expected_root_passes(self) -> None: + assert _MOD.canary_error( + "/scratch/base/vera/__init__.py", Path("/scratch/base"), "base" + ) is None + + def test_a_compiler_outside_the_expected_root_is_an_error(self) -> None: + message = _MOD.canary_error( + "/usr/lib/site-packages/vera/__init__.py", + Path("/scratch/base"), + "base", + ) + assert message is not None + assert "base" in message + assert "/usr/lib/site-packages/vera/__init__.py" in message + assert "/scratch/base" in message + + def test_an_import_failure_is_an_error_that_says_so(self) -> None: + """A side that could not import `vera` at all reports no path; + that is a failed run, not an absent objection. + + The message must say the import failed. Asserting only that + *some* message came back is satisfied by the wrong branch: an + empty path resolves to the process's own directory, which is not + under the expected root either, so a missing import-failure + check still objects — while claiming the side compiled with + another checkout's compiler, which is not what happened. + """ + message = _MOD.canary_error("", Path("/scratch/base"), "base") + assert message is not None + assert "base" in message + assert "could not import" in message + + +# --------------------------------------------------------------------------- +# Collection +# --------------------------------------------------------------------------- + + +class TestCollection: + """How per-file results are keyed, with the compile injected.""" + + def test_results_are_keyed_by_repo_relative_posix_path(self) -> None: + """Both sides compile the *working tree's* files, so both maps + must be keyed against that one corpus root. An absolute key + would work only by accident — the base compiler runs from a + scratch checkout elsewhere — and a side-specific key would leave + every program unreported. POSIX form because the key is + compared as a string (CLAUDE.md's cross-platform rule).""" + root = Path("/repo") + files = [ + root / "examples" / "a.vera", + root / "tests" / "conformance" / "vera" / "b.vera", + ] + seen: list[Path] = [] + + def fake_compile(path: Path) -> Any: + seen.append(path) + return _ok(f"digest-of-{path.name}") + + results = _MOD.collect(files, root, fake_compile) + + assert set(results) == { + "examples/a.vera", + "tests/conformance/vera/b.vera", + } + assert results["examples/a.vera"].digest == "digest-of-a.vera" + assert seen == files + + +# --------------------------------------------------------------------------- +# The failure reason +# --------------------------------------------------------------------------- + + +class TestFailureReason: + """What a one-sided mover's line says the compile failed of. + + Measured, not imagined: the first version took the first line of + stderr that did not begin with ``warning:``, and a real run against + v0.1.9 reported a *warning's* quoted source line + (``public fn read_some(@Unit -> @Int)``) as the reason a program did + not compile. A diagnostic is a block, and only its first line + carries the marker. + """ + + _WARNING_BLOCK = ( + "warning: [E604] Error at /repo/x.vera, line 3, column 1:\n" + "\n" + " public fn read_some(@Unit -> @Int)\n" + " ^\n" + "\n" + " Function 'read_some' has unsupported parameter type.\n" + ) + + def test_the_reason_is_the_error_not_a_warnings_source_line(self) -> None: + stderr = ( + self._WARNING_BLOCK + + "[E154] Error at /repo/x.vera, line 9, column 8:\n" + "\n public forall fn pick(@VeraFn -> @Int)\n" + ) + reason = _MOD._first_error(stderr, Path("/repo/x.vera")) + assert "[E154]" in reason + assert "read_some" not in reason + + def test_the_compiled_files_path_is_not_repeated_in_the_reason( + self, + ) -> None: + """The reason is already attached to a named program, and the + absolute path of a corpus file under a scratch checkout is long + enough to push the diagnostic out of the truncated line.""" + stderr = "[E154] Error at /repo/x.vera, line 9, column 8:\n" + reason = _MOD._first_error(stderr, Path("/repo/x.vera")) + assert "/repo/x.vera" not in reason + assert "x.vera" in reason + + def test_a_crash_reports_its_exception_not_its_first_line(self) -> None: + """No diagnostic marker at all — a compiler crash. The useful + line is the exception, which is last.""" + stderr = ( + "Traceback (most recent call last):\n" + ' File "/repo/vera/cli.py", line 1, in main\n' + "AssertionError: slot table is empty\n" + ) + reason = _MOD._first_error(stderr, Path("/repo/x.vera")) + assert reason == "AssertionError: slot table is empty" + + def test_silence_still_gives_a_reason(self) -> None: + assert _MOD._first_error("", Path("/repo/x.vera")) != "" + + +# --------------------------------------------------------------------------- +# What the instrument is not +# --------------------------------------------------------------------------- + + +class TestNotAPreCommitHook: + """The module docstring's claim, asserted rather than trusted.""" + + def test_the_instrument_is_not_wired_into_pre_commit(self) -> None: + """It compiles the whole corpus twice. As a commit hook that is + minutes per commit, which is why it is a burndown instrument the + maintainer runs deliberately. If it is ever wired in, the + module docstring saying it is not must change in the same + commit.""" + config = (_ROOT / ".pre-commit-config.yaml").read_text( + encoding="utf-8" + ) + assert "check_corpus_differential" not in config diff --git a/tests/test_check_doc_counts.py b/tests/test_check_doc_counts.py index 8e5f44ba..8251e924 100644 --- a/tests/test_check_doc_counts.py +++ b/tests/test_check_doc_counts.py @@ -663,3 +663,281 @@ def test_a_directory_that_is_not_a_repository_is_no_evidence( plain = tmp_path / "plain" plain.mkdir() assert _MOD.release_tags(plain) is None + + +# --------------------------------------------------------------------------- +# README's project-status line (#1290 rider): the sentence gated the tests +# figure and nothing else on it. The conformance count beside it drifted +# through two rebases unseen, because `check_readme` returned silently when a +# pattern matched nothing — four of its five patterns matched nothing at all. +# --------------------------------------------------------------------------- + +_STATUS = ( + "Vera is in **active development** at v0.1.11: 2,000+ commits, 209 " + "releases, 11,134 tests, 95% Python code coverage, 229 conformance " + "programs, 42 examples, and a 14-chapter specification.\n" +) + + +class TestProjectStatusLine: + def test_the_shipped_line_is_consistent(self) -> None: + assert _MOD.check_project_status(_STATUS, 11134, 229, 42, 14) == [] + + def test_every_count_on_the_line_is_gated(self) -> None: + """One error per wrong figure, and the conformance one is among them.""" + errors = _MOD.check_project_status(_STATUS, 1, 2, 3, 4) + assert len(errors) == 4 + assert any("conformance" in e for e in errors) + assert any("examples" in e for e in errors) + assert any("chapter" in e for e in errors) + + def test_the_conformance_count_alone_is_caught(self) -> None: + """The measured drift: tests right, conformance stale beside it.""" + errors = _MOD.check_project_status(_STATUS, 11134, 230, 42, 14) + assert len(errors) == 1 + assert "229" in errors[0] and "230" in errors[0] + + def test_a_missing_status_line_is_an_error_not_a_skip(self) -> None: + # Same true numbers, phrasing the pattern cannot see. Returning [] + # here is what let four of the five README gates sit dead. + text = "Vera has 11,134 tests and 229 conformance programs.\n" + errors = _MOD.check_project_status(text, 11134, 229, 42, 14) + assert len(errors) == 1 + assert "could not find" in errors[0] + + def test_a_count_dropped_from_the_line_is_an_error_not_a_skip(self) -> None: + text = _STATUS.replace("229 conformance programs, ", "") + errors = _MOD.check_project_status(text, 11134, 229, 42, 14) + assert len(errors) == 1 + assert "could not find" in errors[0] + + def test_the_counts_are_read_from_the_status_line_only(self) -> None: + """A decoy elsewhere in the file must not satisfy the gate.""" + text = "Elsewhere: 999 conformance programs.\n\n" + _STATUS + assert _MOD.check_project_status(text, 11134, 229, 42, 14) == [] + + +# --------------------------------------------------------------------------- +# TESTING.md's dual-target row (#1290 rider): a run-level total from the +# manifest, and a tested/skipped split with three category counts that no +# oracle read. +# --------------------------------------------------------------------------- + +_DUAL_ROW = ( + "the **dual-target conformance differential** (all 168 run-level " + "conformance programs driven under both targets, byte-identical " + "stdout/stderr required — 118 are dual-tested and 50 skip *loudly* " + "rather than passing silently: 43 whose compiled WAT imports a host " + "family outside `IO`/`Random`, 6 with no public zero-argument `main`, " + "and 1 calling a nondeterministic op.)\n" +) + + +def _split(**overrides: int) -> Any: + values = dict(tested=118, skipped=50, families=43, no_main=6, nondeterministic=1) + values.update(overrides) + return _MOD.DualTargetSplit(**values) + + +class TestDualTargetRow: + def test_the_shipped_row_is_consistent(self) -> None: + assert _MOD.check_dual_target_row(_DUAL_ROW, 168, _split()) == [] + + def test_the_run_level_total_comes_from_the_manifest(self) -> None: + errors = _MOD.check_dual_target_row(_DUAL_ROW, 169, _split()) + assert [e for e in errors if "run-level total" in e] + + def test_each_part_of_the_split_is_gated(self) -> None: + errors = _MOD.check_dual_target_row( + _DUAL_ROW, 168, _split(tested=117, skipped=51) + ) + assert len(errors) == 2 + + def test_each_category_is_gated(self) -> None: + errors = _MOD.check_dual_target_row( + _DUAL_ROW, 168, _split(families=42, no_main=7, nondeterministic=2) + ) + assert len(errors) == 3 + + def test_the_split_must_sum_to_the_run_level_total(self) -> None: + """Three consistent-looking numbers that do not add up is drift.""" + errors = _MOD.check_dual_target_row( + _DUAL_ROW.replace("all 168 run-level", "all 200 run-level"), + 200, + _split(), + ) + assert [e for e in errors if "does not add up" in e] + + def test_the_categories_must_sum_to_the_skip_total(self) -> None: + row = _DUAL_ROW.replace("and 1 calling", "and 2 calling") + errors = _MOD.check_dual_target_row(row, 168, _split(nondeterministic=2)) + assert [e for e in errors if "do not add up" in e] + + def test_a_reworded_row_is_an_error_not_a_skip(self) -> None: + # The same true numbers, phrased so no pattern sees them. + text = "The differential drives 168 programmes, skipping 50 of them.\n" + errors = _MOD.check_dual_target_row(text, 168, _split()) + assert [e for e in errors if "could not find" in e] + + def test_one_reworded_figure_is_an_error_not_a_skip(self) -> None: + """The row still parses; a single category has been reworded away. + + Every other figure agrees, so a silent skip here leaves the row + looking checked while one of its five numbers is unread. + """ + row = _DUAL_ROW.replace("43 whose compiled WAT", "forty-three whose WAT") + errors = _MOD.check_dual_target_row(row, 168, _split()) + assert len(errors) == 1 + assert "could not find" in errors[0] and "families" in errors[0] + + def test_the_live_split_is_read_from_the_test_run(self) -> None: + """Non-vacuity: parsed from real ``-rs`` output, not from the doc.""" + report = ( + "SKIPPED [43] tests/test_wasi_target.py:1130: family gate: " + "--target wasi-p2 does not support the following host family: map\n" + "SKIPPED [6] tests/test_wasi_target.py:1130: family gate: " + "--target wasi-p2 requires a public zero-argument `main` entry point\n" + "SKIPPED [1] tests/test_wasi_target.py:1124: nondeterministic ops " + "['random_int']\n" + "118 passed, 50 skipped in 3.15s\n" + ) + assert _MOD.parse_dual_target_report(report) == _split() + + def test_an_unclassified_skip_is_an_error_not_a_skip(self) -> None: + report = ( + "SKIPPED [50] tests/test_wasi_target.py:1130: some new reason\n" + "118 passed, 50 skipped in 3.15s\n" + ) + assert _MOD.parse_dual_target_report(report) is None + + def test_an_unclassified_skip_beside_a_correct_total_is_still_an_error( + self, + ) -> None: + """The three documented categories already account for every skip. + + Folding a fourth reason into none of them leaves the arithmetic + looking right, so the sum reconciliation alone cannot catch it. + """ + report = ( + "SKIPPED [43] host family: map\n" + "SKIPPED [6] requires a public zero-argument `main`\n" + "SKIPPED [1] nondeterministic ops ['random_int']\n" + "SKIPPED [3] tests/test_wasi_target.py:9: a brand new reason\n" + "118 passed, 50 skipped in 3.15s\n" + ) + assert _MOD.parse_dual_target_report(report) is None + + def test_classified_skips_that_miss_the_summary_total_are_an_error(self) -> None: + """Every reason is known and they still do not account for the run. + + The complement of the case above: the unclassified-reason guard is + satisfied here, so only the sum reconciliation can catch it. + """ + report = ( + "SKIPPED [43] host family: map\n" + "SKIPPED [6] requires a public zero-argument `main`\n" + "SKIPPED [1] nondeterministic ops ['random_int']\n" + "118 passed, 55 skipped in 3.15s\n" + ) + assert _MOD.parse_dual_target_report(report) is None + + def test_a_report_with_no_summary_line_is_an_error_not_a_skip(self) -> None: + assert _MOD.parse_dual_target_report("nothing to see here\n") is None + + +# --------------------------------------------------------------------------- +# KNOWN_ISSUES' Bugs table (#1290 rider): one row per open `bug` issue. +# +# The parity half needs the GitHub API, which a pre-commit hook must not +# depend on, so it is opt-in: `--check-bug-issues` at release-PR time. The +# structural half is pure text and always on. +# --------------------------------------------------------------------------- + + +def _bugs(*rows: str) -> str: + body = "\n".join(rows) + return f"# Known Issues\n\n## Bugs\n\n| Bug | Issue |\n|-----|-------|\n{body}\n\n## Limitations\n" + + +def _row(number: int, text: str = "Something is wrong.") -> str: + url = f"https://github.com/aallan/vera/issues/{number}" + return f"| {text} | [#{number}]({url}) |" + + +class TestBugRows: + def test_the_shipped_table_parses(self) -> None: + text = (Path(__file__).parent.parent / "KNOWN_ISSUES.md").read_text( + encoding="utf-8" + ) + rows = _MOD.bug_rows(text) + assert rows is not None + assert len(rows) > 5, "the Bugs table is no longer being read" + assert len(set(rows)) == len(rows) + + def test_a_row_with_no_issue_link_is_an_error(self) -> None: + text = _bugs("| A bug with no tracker. | none |") + errors = _MOD.check_bug_rows(text) + assert len(errors) == 1 and "not found" in errors[0] + + def test_two_rows_for_one_issue_are_an_error(self) -> None: + """One-to-one: two rows citing one issue is a duplicate, not two bugs.""" + text = _bugs(_row(101), _row(101, "The same bug again.")) + errors = _MOD.check_bug_rows(text) + assert len(errors) == 1 and "twice" in errors[0] + + def test_a_link_whose_number_and_url_disagree_is_an_error(self) -> None: + text = _bugs( + "| Mislinked. | [#101](https://github.com/aallan/vera/issues/202) |" + ) + errors = _MOD.check_bug_rows(text) + assert len(errors) == 1 and "not found" in errors[0] + + def test_a_pull_request_link_is_not_an_issue_link(self) -> None: + text = _bugs("| Wrong kind. | [#101](https://github.com/aallan/vera/pull/101) |") + errors = _MOD.check_bug_rows(text) + assert len(errors) == 1 and "not found" in errors[0] + + def test_a_row_carrying_a_pipe_in_its_prose_still_parses(self) -> None: + text = _bugs(_row(101, "The `|>` operator is wrong.")) + assert _MOD.check_bug_rows(text) == [] + + def test_the_no_known_bugs_convention_is_not_an_empty_table(self) -> None: + text = "# Known Issues\n\n## Bugs\n\nNo known bugs.\n\n## Limitations\n" + assert _MOD.bug_rows(text) == [] + assert _MOD.check_bug_rows(text) == [] + + def test_an_empty_bugs_section_is_an_error_not_a_skip(self) -> None: + text = "# Known Issues\n\n## Bugs\n\n## Limitations\n" + errors = _MOD.check_bug_rows(text) + assert len(errors) == 1 and "not found" in errors[0] + + def test_a_renamed_heading_is_an_error_not_a_skip(self) -> None: + text = _bugs(_row(101)).replace("## Bugs", "## Open bugs") + errors = _MOD.check_bug_rows(text) + assert len(errors) == 1 and "not found" in errors[0] + + +class TestBugIssueParity: + def test_a_matching_pair_of_sets_is_clean(self) -> None: + assert _MOD.check_bug_issue_parity([101, 102], [102, 101]) == [] + + def test_an_open_bug_issue_with_no_row_is_reported(self) -> None: + errors = _MOD.check_bug_issue_parity([101], [101, 102]) + assert len(errors) == 1 and "#102" in errors[0] + + def test_a_row_whose_issue_is_not_an_open_bug_is_reported(self) -> None: + errors = _MOD.check_bug_issue_parity([101, 103], [101]) + assert len(errors) == 1 and "#103" in errors[0] + + def test_no_open_bug_issues_is_an_error_not_a_skip(self) -> None: + """An empty fetch is a failed query, not a clean bill of health.""" + errors = _MOD.check_bug_issue_parity([101], []) + assert [e for e in errors if "not found" in e] + + def test_the_parity_check_is_not_wired_into_the_default_run(self) -> None: + """A pre-commit hook must not depend on the GitHub API.""" + source = _SCRIPT.read_text(encoding="utf-8") + assert "--check-bug-issues" in source + index = source.rindex("check_bug_issue_parity(") + guarded = source[max(0, index - 600) : index] + assert "args.check_bug_issues" in guarded diff --git a/tests/test_check_examples_run.py b/tests/test_check_examples_run.py index ff75da35..243fbce7 100644 --- a/tests/test_check_examples_run.py +++ b/tests/test_check_examples_run.py @@ -79,6 +79,65 @@ def _load() -> Any: """ +# A program whose only external-resource signal is its declared effect +# ROW: `DB` is in ``RESOURCE_EFFECTS``, while `DB.execute` is not in +# ``RESOURCE_OPS`` — so a derivation that stopped reading effect rows +# would find nothing here. +_DB_SRC = """\ +public fn main(-> @Int) + requires(true) + ensures(true) + effects() +{ + let @Array> = []; + match DB.execute("CREATE TABLE t (id INTEGER)", @Array>.0) { + Err(@String) -> { + IO.print(@String.0); + 1 + }, + Ok(@Int) -> { + IO.print("created the table"); + 0 + } + } +} +""" + +# The mirror image: the only signal is an operation CALL. `IO` is not in +# ``RESOURCE_EFFECTS`` — sixteen examples declare a bare `` and only +# one touches the filesystem — so a derivation that stopped reading call +# sites would find nothing here. +_FILE_SRC = """\ +public fn main(-> @Unit) + requires(true) + ensures(true) + effects() +{ + match IO.read_file("hello.txt") { + Ok(@String) -> IO.print(@String.0), + Err(@String) -> IO.print(@String.0) + } +} +""" + +# Both shapes in prose only. A text scan would call this a database +# program; the derivation reads the parsed declarations, so it does not. +_COMMENTED_SRC = """\ +-- Names the effect and IO.read_file in prose, and uses neither. +public fn main(-> @Int) + requires(true) + ensures(true) + effects(pure) +{ + -- effects() would go here, and IO.write_file("a", "b") below. + 1 + 1 +} +""" + +# Not Vera at all — the parse must fail rather than yield "no signals". +_UNPARSEABLE_SRC = "public fn main(-> @Int) { this is not Vera at all\n" + + def _corpus(tmp_path: Path, programs: dict[str, str]) -> Path: d = tmp_path / "examples" d.mkdir(exist_ok=True) @@ -226,14 +285,23 @@ def test_no_spec_relies_on_the_first_export_fallback(self) -> None: def test_environment_dependent_specs_carry_an_output_sentinel( self, ) -> None: - """Three examples reach outside the process — a committed SQLite - fixture, an in-memory database, the filesystem — and each answers a - failure by printing a message and completing normally. Exit code - alone cannot tell their success path from their graceful one, so - each must pin a substring only the success path prints.""" - for name in ("sqlitedb", "database", "file_io"): - spec = _MOD.RUN_SPECS[name] - assert spec.expect, f"{name} has no expected-output sentinel" + """An example that reaches outside the process answers a failure by + printing a message and completing normally, so exit code alone + cannot tell its success path from its graceful one: each must pin a + substring only the success path prints. + + WHICH examples those are is derived from the corpus, not listed + here. A literal `("sqlitedb", "database", "file_io")` asserts the + sentinels the corpus already has and nothing about the next + example of the same kind — the one case the rule exists for. So + the assertion is the two-way equality between the examples that + *declare* an external resource and the specs that carry an + `expect`, which a new database or filesystem example joins by + being written rather than by being remembered. + """ + assert _MOD.check_sentinel_coverage( + _ROOT / "examples", _MOD.RUN_SPECS + ) == [] def test_every_skip_property_is_documented(self) -> None: for name, prop in _MOD.SKIPS.items(): @@ -245,6 +313,259 @@ def test_no_unused_skip_property(self) -> None: assert set(_MOD.SKIP_PROPERTIES) == set(_MOD.SKIPS.values()) +# --------------------------------------------------------------------------- +# The derived sentinel rule +# --------------------------------------------------------------------------- + + +class TestResourceSignalDerivation: + """Which examples must pin a sentinel is read off the examples. + + The declared constants are resource *names* — the `DB` effect, the + `IO.read_file` / `IO.write_file` operations — and the example set + follows from which programs declare them. Names rather than + filenames is the whole point: a filename list is a snapshot of + today's corpus, and the case the rule exists for is tomorrow's + example. + + An effect row alone cannot discriminate, which is why the operations + are read too: `FileIO` and `Time` are not effects in Vera, so + `file_io.vera` declares the same bare `` that `hello_world.vera` + does, and only the `IO.read_file` call tells them apart. + """ + + def test_declared_resource_names_are_all_live(self) -> None: + """Every name in ``RESOURCE_EFFECTS`` / ``RESOURCE_OPS`` still + exists in the effect registry the compiler serves.""" + assert _MOD.resource_registry_errors() == [] + + def test_an_effect_row_signal_is_derived(self, tmp_path: Path) -> None: + """A `` in the effect row is a signal. `DB.execute` is not in + ``RESOURCE_OPS``, so this program's whole signal comes from the + row — an exact-set assertion, so an implementation that credited + the call site instead would not pass.""" + d = _corpus(tmp_path, {"dbish": _DB_SRC}) + assert _MOD.resource_signals(d / "dbish.vera") == frozenset({"DB"}) + + def test_an_operation_call_signal_is_derived( + self, tmp_path: Path + ) -> None: + """The mirror image: `IO` is not a resource effect, so this + program's whole signal comes from the `IO.read_file` call.""" + d = _corpus(tmp_path, {"filish": _FILE_SRC}) + assert _MOD.resource_signals(d / "filish.vera") == frozenset( + {"IO.read_file"} + ) + + def test_a_resource_free_example_declares_no_signal( + self, tmp_path: Path + ) -> None: + """The other direction, without which a derivation that returned + every name for every program would pass the two above.""" + d = _corpus(tmp_path, {"pure": _CLEAN_SRC}) + assert _MOD.resource_signals(d / "pure.vera") == frozenset() + + def test_signals_come_from_the_declarations_not_the_comments( + self, tmp_path: Path + ) -> None: + """The derivation reads the parsed program, so prose naming `` + or `IO.read_file` is not a resource declaration. + + Not hypothetical: `examples/sqlitedb.vera`'s first line is a + comment containing ``, so a text scan would agree with the + parse there by luck and disagree on the first example whose + header describes what it deliberately does *not* do. + """ + d = _corpus(tmp_path, {"prose": _COMMENTED_SRC}) + assert _MOD.resource_signals(d / "prose.vera") == frozenset() + + def test_a_renamed_resource_effect_is_an_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A resource effect the registry no longer has must fail loudly. + + Silently, it would match no example — and with nothing left + requiring a sentinel the rule switches itself off. The + *registry* diagnosis is asserted, not merely that an error came + back: the coinciding-message trap, since the matched-nothing + guard also fires on this input and reads as a different fault. + """ + monkeypatch.setattr(_MOD, "RESOURCE_EFFECTS", ("Databayse",)) + d = _corpus(tmp_path, {"dbish": _DB_SRC}) + errors = _MOD.check_sentinel_coverage(d, {"dbish": _MOD.RunSpec()}) + assert len(errors) == 1 + assert "Databayse" in errors[0] + assert "could not find" in errors[0] + assert "no longer gated" not in errors[0] + + def test_a_renamed_resource_op_is_an_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The same for an operation: `IO` exists, `read_flie` does not.""" + monkeypatch.setattr( + _MOD, "RESOURCE_OPS", (("IO", "read_flie"),) + ) + d = _corpus(tmp_path, {"filish": _FILE_SRC}) + errors = _MOD.check_sentinel_coverage(d, {"filish": _MOD.RunSpec()}) + assert len(errors) == 1 + assert "read_flie" in errors[0] + assert "could not find" in errors[0] + assert "no longer gated" not in errors[0] + + def test_a_resource_op_under_an_unknown_effect_is_an_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """An operation is only meaningful under an effect that exists, and + looking its name up under a missing one must not be read as the + operation being absent.""" + monkeypatch.setattr( + _MOD, "RESOURCE_OPS", (("FileIO", "read_file"),) + ) + d = _corpus(tmp_path, {"filish": _FILE_SRC}) + errors = _MOD.check_sentinel_coverage(d, {"filish": _MOD.RunSpec()}) + assert len(errors) == 1 + assert "FileIO" in errors[0] + assert "could not find" in errors[0] + + +class TestDerivedSentinelCoverage: + """The two-way equality: the examples that declare an external + resource are exactly the specs that carry an ``expect``. + + Both directions are errors. A resource-touching example with no + sentinel passes on its graceful arm the day its fixture vanishes, + which is the failure the sentinels exist to catch; a sentinel on an + example with no resource re-pins stdout the dedicated tests own and + goes red on a cosmetic edit. + """ + + def test_a_new_database_example_needs_a_sentinel( + self, tmp_path: Path + ) -> None: + """The property a hard-coded triple could not have: an example + under a name no table has ever heard of is required to pin a + sentinel because of what it declares.""" + d = _corpus(tmp_path, {"brand_new_db": _DB_SRC}) + errors = _MOD.check_sentinel_coverage( + d, {"brand_new_db": _MOD.RunSpec()} + ) + assert len(errors) == 1 + assert "brand_new_db" in errors[0] + assert "expect" in errors[0] + + def test_a_new_filesystem_example_needs_a_sentinel( + self, tmp_path: Path + ) -> None: + """The same through the operation half of the derivation, which + the effect row cannot reach: `` is what sixteen examples + declare, and only the `IO.read_file` call marks this one.""" + d = _corpus(tmp_path, {"brand_new_file": _FILE_SRC}) + errors = _MOD.check_sentinel_coverage( + d, {"brand_new_file": _MOD.RunSpec()} + ) + assert len(errors) == 1 + assert "brand_new_file" in errors[0] + assert "expect" in errors[0] + + def test_a_resource_touching_example_with_a_sentinel_passes( + self, tmp_path: Path + ) -> None: + """The green direction, without which a check that always + returned an error would pass the two above.""" + d = _corpus(tmp_path, {"dbish": _DB_SRC, "filish": _FILE_SRC}) + assert _MOD.check_sentinel_coverage( + d, + { + "dbish": _MOD.RunSpec(expect="created the table"), + "filish": _MOD.RunSpec(expect="Hello from Vera!"), + }, + ) == [] + + def test_a_sentinel_on_a_resource_free_example_is_an_error( + self, tmp_path: Path + ) -> None: + """The other direction of the equality. Without it the rule is + one-way and a spec can pin stdout on any example at all — which + is the duplication of the dedicated output tests that the gate's + design deliberately refuses.""" + d = _corpus(tmp_path, {"dbish": _DB_SRC, "pure": _CLEAN_SRC}) + errors = _MOD.check_sentinel_coverage( + d, + { + "dbish": _MOD.RunSpec(expect="created the table"), + "pure": _MOD.RunSpec(expect="2"), + }, + ) + assert len(errors) == 1 + assert "pure" in errors[0] + assert "dbish" not in errors[0] + + def test_an_empty_derived_set_is_an_error_not_a_skip( + self, tmp_path: Path + ) -> None: + """A derivation that matches nothing must fail, not pass. + + The corpus below is resource-free and its specs pin nothing, so + both sides of the equality are empty and the equality *holds* — + a vacuous green that would also be the verdict if the walk + broke, an effect were renamed, or the parse silently returned + nothing. The whole rule would be switched off and every gate + run would report success. + """ + d = _corpus(tmp_path, {"pure": _CLEAN_SRC, "also_pure": _CLEAN_SRC}) + errors = _MOD.check_sentinel_coverage( + d, {"pure": _MOD.RunSpec(), "also_pure": _MOD.RunSpec()} + ) + assert len(errors) == 1 + assert "no longer gated" in errors[0] + + def test_an_unparseable_example_is_an_error_not_an_empty_signal_set( + self, tmp_path: Path + ) -> None: + """A program the parse cannot read has *unknown* signals, and + unknown must not be spelled the same as none. + + Read as none, a file that stopped parsing would silently leave + the sentinel rule — and the diagnosis a reader got would be the + opposite one, that its sentinel covers no resource. + """ + d = _corpus(tmp_path, {"broken": _UNPARSEABLE_SRC, "dbish": _DB_SRC}) + errors = _MOD.check_sentinel_coverage( + d, + { + "broken": _MOD.RunSpec(), + "dbish": _MOD.RunSpec(expect="created the table"), + }, + ) + assert len(errors) == 1 + assert "broken" in errors[0] + assert "could not be parsed" in errors[0] + + def test_main_computes_the_rule_and_reports_it(self) -> None: + """A structural pin on the wiring, the technique + `test_the_runner_hands_both_streams_to_the_output_check` uses and + for the same reason: reaching `main` end to end means running all + 34 examples under the native runtime, which no unit test can + afford, so nothing else here can distinguish a `main` that + computes the sentinel errors from one that drops them on the + floor. That mutant is the most consequential of the lot — the + rule would hold in this file and gate nothing in CI — so it gets + the tripwire. + """ + import inspect + + src = inspect.getsource(_MOD.main) + assert re.search( + r"sentinel_errors\s*=\s*check_sentinel_coverage\(", src + ), src + # And the result reaches the report, rather than being computed + # and discarded: passing `[]` in its place would satisfy a + # presence check on the name alone. + assert re.search( + r"error_blocks\(\s*\[\]\s*,\s*sentinel_errors\s*,", src + ), src + + class TestBuildCommand: def test_default_spec_names_main_explicitly(self) -> None: """No spec leaves the entry point implicit — see @@ -699,6 +1020,7 @@ def test_doc_errors_are_never_filed_under_the_coverage_count( """ blocks = _MOD.error_blocks( coverage_errors=["one coverage problem"], + sentinel_errors=[], doc_errors=["one doc problem", "another doc problem"], failures=[], ) @@ -722,15 +1044,34 @@ def test_doc_errors_are_never_filed_under_the_coverage_count( assert counted == {1, 2} def test_error_blocks_are_empty_when_nothing_is_wrong(self) -> None: - assert _MOD.error_blocks([], [], []) == [] + assert _MOD.error_blocks([], [], [], []) == [] def test_runtime_failures_get_their_own_counted_block(self) -> None: - blocks = _MOD.error_blocks([], [], ["boom: exited 1"]) + blocks = _MOD.error_blocks([], [], [], ["boom: exited 1"]) assert ( _sole_index(blocks, "RUNTIME FAILURES (1)") < _sole_index(blocks, "boom: exited 1") ) + def test_sentinel_errors_get_their_own_counted_block(self) -> None: + """The derived sentinel rule reports under its own header, for the + same reason the other three do: filed under `COVERAGE ERRORS (n)` + its lines would be attributed to a count that excludes them. + + Positional, like the doc-error test above — presence plus counts + is satisfied by a report that emits every header and then every + line. + """ + blocks = _MOD.error_blocks( + ["one coverage problem"], ["one sentinel problem"], [], [], + ) + assert ( + _sole_index(blocks, "COVERAGE ERRORS (1)") + < _sole_index(blocks, "one coverage problem") + < _sole_index(blocks, "SENTINEL COVERAGE (1)") + < _sole_index(blocks, "one sentinel problem") + ) + # --------------------------------------------------------------------------- # The TESTING.md cross-check diff --git a/tests/test_grammar_alignment.py b/tests/test_grammar_alignment.py index 6478775b..757ab661 100644 --- a/tests/test_grammar_alignment.py +++ b/tests/test_grammar_alignment.py @@ -307,3 +307,227 @@ def test_drift_reports_a_rotted_allowlist_entry() -> None: assert stale == ["program"] assert actionable == [] assert unsound == [] + + +# --------------------------------------------------------------------------- +# Terminals and production bodies (#1290) +# +# Each of these three classes was demonstrated green on a live file before the +# checks existed: a fabricated terminal in §10.2, a rule reference restored to +# a right-hand side, and a production body edited on one side only. +# --------------------------------------------------------------------------- + + +def _lark_lines() -> list[str]: + return _lark_text().splitlines() + + +def _spec_lines() -> list[str]: + return _MOD.ebnf_fence_lines((_ROOT / _MOD.SPEC).read_text(encoding="utf-8")) + + +def _messages(*problems: list[str]) -> str: + return "\n".join(line for group in problems for line in group) + + +class TestTerminalAudit: + def test_the_shipped_files_are_clean(self) -> None: + assert _MOD.terminal_audit(_lark_lines(), _spec_lines()) == [] + + def test_a_fabricated_spec_terminal_is_caught(self) -> None: + """The demonstrated blind spot: `_HEADER` needs a lowercase lead.""" + spec = [*_spec_lines(), 'BOGUS_TERMINAL: "bogus"'] + problems = _MOD.terminal_audit(_lark_lines(), spec) + assert [p for p in problems if "BOGUS_TERMINAL" in p and "never used" in p] + + def test_a_referenced_but_undeclared_terminal_is_caught(self) -> None: + """The `DOUBLE_COLON` shape: used by a production, declared nowhere.""" + lark = [ + line.replace("UPPER_IDENT", "PHANTOM_IDENT") + if line.startswith("slot_ref:") + else line + for line in _lark_lines() + ] + assert "PHANTOM_IDENT" in "\n".join(lark) + problems = _MOD.terminal_audit(lark, _spec_lines()) + assert [p for p in problems if "PHANTOM_IDENT" in p and "never declared" in p] + + def test_deleting_a_terminal_still_in_use_is_caught(self) -> None: + lark = [line for line in _lark_lines() if not line.startswith("INT_LIT:")] + problems = _MOD.terminal_audit(lark, _spec_lines()) + assert [p for p in problems if "INT_LIT" in p and "never declared" in p] + + def test_a_missing_skipped_group_is_an_error_not_a_skip(self) -> None: + """Losing the marker must fail, not silently waive every terminal.""" + spec = [ + line.replace("(skipped)", "(ignored by the lexer)") for line in _spec_lines() + ] + problems = _MOD.terminal_audit(_lark_lines(), spec) + assert [p for p in problems if "no terminal group marked" in p] + + def test_a_note_between_declarations_does_not_end_the_skipped_group(self) -> None: + """A comment after a declaration annotates it; it opens no new group.""" + assert "BLOCK_COMMENT" in _MOD.skipped_terminals(_spec_lines()) + assert "ANNOTATION_COMMENT" in _MOD.skipped_terminals(_spec_lines()) + + def test_the_skipped_group_does_not_swallow_the_whole_fence(self) -> None: + skipped = _MOD.skipped_terminals(_spec_lines()) + assert "FN" not in skipped and "INT_LIT" not in skipped + + +class TestTerminalPatterns: + def test_the_shipped_files_are_clean(self) -> None: + assert _MOD.terminal_patterns(_lark_lines(), _spec_lines()) == [] + + def test_the_non_nesting_block_comment_regex_is_caught(self) -> None: + """The live drift #1290 named: §1.3 says they nest, the regex did not.""" + spec = [ + line + for line in _spec_lines() + if not line.startswith(("BLOCK_COMMENT:", "// Block comments nest")) + ] + spec.append(r"BLOCK_COMMENT: /\{-[\s\S]*?-\}/") + problems = _MOD.terminal_patterns(_lark_lines(), spec) + assert [p for p in problems if "BLOCK_COMMENT" in p] + + def test_a_lark_terminal_missing_from_the_chapter_is_caught(self) -> None: + spec = [line for line in _spec_lines() if not line.startswith("FLOAT_LIT:")] + problems = _MOD.terminal_patterns(_lark_lines(), spec) + assert [p for p in problems if "FLOAT_LIT" in p and "only in" in p] + + def test_a_pattern_that_drifted_is_caught(self) -> None: + spec = [ + "INT_LIT: /[0-9]+/" if line.startswith("INT_LIT:") else line + for line in _spec_lines() + ] + problems = _MOD.terminal_patterns(_lark_lines(), spec) + assert [p for p in problems if "INT_LIT" in p] + + @pytest.mark.parametrize( + ("body", "expected"), + [ + (r"\"([^\"\\]|\\.)*\"", r'"([^"\\]|\\.)*"'), + (r"\/\*[^*]*\*\/", r"/\*[^*]*\*/"), + (r"[^/*]", r"[^/*]"), + # An escaped backslash is copied whole, so the `\"` after it is + # still an escape of the quote and not part of a `\\"` triple. + (r"\\\"", r"\\" + '"'), + ], + ) + def test_normalise_pattern(self, body: str, expected: str) -> None: + assert _MOD.normalise_pattern(body) == expected + + def test_the_two_files_spell_string_lit_differently_and_still_agree(self) -> None: + """Non-vacuity: the normalisation is doing work, not comparing equals.""" + lark = _MOD.terminal_declarations(_lark_lines())["STRING_LIT"] + spec = _MOD.terminal_declarations(_spec_lines())["STRING_LIT"] + assert lark != spec + assert _MOD.normalise_pattern(lark) == _MOD.normalise_pattern(spec) + + +class TestBodyDrift: + def test_the_shipped_files_are_clean(self) -> None: + assert _MOD.body_drift(_lark_lines(), _spec_lines()) == [] + + def test_the_comparison_is_not_vacuous(self) -> None: + shared = set(_MOD.rule_bodies(_lark_lines())) & set( + _MOD.rule_bodies(_spec_lines()) + ) + assert len(shared) > 50 + assert {"primary_expr", "statement", "type_expr", "fn_call"} <= shared + + def test_a_restored_ambiguity_on_a_right_hand_side_is_caught(self) -> None: + """The #1290 case: `statement` regaining its assert/assume alternatives.""" + spec = [] + for line in _spec_lines(): + spec.append(line) + if line.startswith("statement:"): + spec.append(" | assert_expr SEMICOLON") + problems = _MOD.body_drift(_lark_lines(), spec) + assert [p for p in problems if p.startswith("statement:")] + + def test_an_undocumented_literal_is_caught(self) -> None: + """Typed holes: `"?"` in Lark, no spec terminal declaring it.""" + spec = [line for line in _spec_lines() if not line.startswith("HOLE:")] + problems = _MOD.body_drift(_lark_lines(), spec) + assert [p for p in problems if 'literal "?"' in p] + + def test_a_dropped_alternative_is_caught(self) -> None: + spec = [ + line + for line in _spec_lines() + if "| refinement_type" not in line and "| fn_type" not in line + ] + problems = _MOD.body_drift(_lark_lines(), spec) + assert [p for p in problems if p.startswith("type_expr:")] + + def test_a_terminal_the_chapter_alone_names_is_caught(self) -> None: + """The `effect_list` defect: an alternative adding only a terminal. + + Every rule reference stays identical, so the rule half of the + comparison sees nothing — this cell is the only thing that dies when + the terminal half is deleted. + """ + spec = [] + for line in _spec_lines(): + spec.append(line) + if line.startswith("effect_list:"): + spec.append(" | UPPER_IDENT // effect variable") + problems = _MOD.body_drift(_lark_lines(), spec) + assert [ + p + for p in problems + if p.startswith("effect_list:") and "UPPER_IDENT" in p and _MOD.SPEC in p + ] + + def test_a_terminal_only_lark_names_is_caught(self) -> None: + spec = [ + line.replace(" SEMICOLON", "") + if line.lstrip().startswith("| expr SEMICOLON") + else line + for line in _spec_lines() + ] + assert "| expr SEMICOLON" not in "\n".join(spec) + problems = _MOD.body_drift(_lark_lines(), spec) + assert [ + p + for p in problems + if p.startswith("statement:") and "SEMICOLON" in p and _MOD.LARK in p + ] + + def test_a_rule_referring_to_itself_is_not_drift(self) -> None: + """Lark spells repetition with left recursion, the chapter with `*`.""" + lark = _MOD.rule_bodies(_lark_lines()) + assert "add_expr" in "".join(lark["add_expr"]), "no longer left-recursive" + assert not [p for p in _MOD.body_drift(_lark_lines(), _spec_lines())] + + def test_a_waived_production_is_folded_at_the_rule_the_waiver_names(self) -> None: + """`fn_call` inlines what the chapter factors into `module_call`.""" + rules, terminals, inlined = _MOD._spec_symbols( + "fn_call", _MOD.rule_bodies(_spec_lines()), set(_MOD.rule_bodies(_spec_lines())) + ) + assert "module_path" in rules + assert {"DOT", "DOUBLE_COLON"} <= terminals + assert "module_call" not in rules and "qualified_call" not in rules + + def test_an_aliased_alternative_is_not_read_as_a_rule_reference(self) -> None: + bodies = _MOD.rule_bodies(_lark_lines()) + assert "func_call" not in "".join(bodies["fn_call"]) + + +class TestCommentStripping: + def test_a_regex_body_ending_in_a_slash_is_not_truncated(self) -> None: + """`line.split("//")[0]` cut the annotation-comment terminal in half.""" + line = r"%ignore /\/\*[^*]*\*+([^\/*][^*]*\*+)*\//" + assert _MOD.strip_comment(line) == line + + def test_a_comment_after_a_regex_is_still_removed(self) -> None: + assert _MOD.strip_comment(r"INT_LIT: /0|[1-9]/ // numbers") == ( + r"INT_LIT: /0|[1-9]/ " + ) + + def test_a_double_slash_inside_a_literal_is_not_a_comment(self) -> None: + assert _MOD.strip_comment('sep: "//" name') == 'sep: "//" name' + + def test_a_whole_line_comment_is_still_removed(self) -> None: + assert _MOD.strip_comment("// assert_stmt: gone").strip() == "" diff --git a/tests/test_release.py b/tests/test_release.py index 0981dc41..451d2a2d 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -4,6 +4,7 @@ import importlib.util import os +import re from pathlib import Path import subprocess import sys @@ -140,6 +141,151 @@ def test_section_requires_a_bullet(self, body: str) -> None: with pytest.raises(release.ReleaseError, match="at least one bullet"): release.changelog_notes(f"## [0.1.5]{body}", "0.1.5") + def test_section_carries_the_heading_date(self) -> None: + section = release.changelog_section("## [0.1.5] - 2026-07-15\n\n- One.\n", "0.1.5") + assert (section.version, section.date, section.notes) == ( + "0.1.5", + "2026-07-15", + "- One.", + ) + + def test_section_without_a_date_reports_none(self) -> None: + assert release.changelog_section("## [0.1.5]\n\n- One.\n", "0.1.5").date is None + + +def _section(bullets: str, *, version: str = "0.1.5") -> Any: + return release.changelog_section( + f"## [{version}] - 2026-07-15\n\n{bullets}\n", version + ) + + +class TestReleaseBody: + """#1288 — the GitHub Release body must always fit the 125,000 limit. + + The v0.1.10 failure landed *after* PyPI had accepted the immutable + archives and after the tag was cut, so the notes builder is required to + be total: it either passes the section through or condenses it, and the + result never exceeds the limit. + """ + + def test_a_section_within_budget_passes_through_unchanged(self) -> None: + section = _section("### Fixed\n\n- **One.** Detail.\n- **Two.** Detail.") + assert release.release_body(section, repo="aallan/vera") == section.notes + + def test_an_oversized_section_is_condensed_to_fit(self) -> None: + filler = "x" * 4000 + bullets = "### Fixed\n\n" + "\n".join( + f"- **Lead-in {index}.** {filler}" for index in range(50) + ) + section = _section(bullets) + assert len(section.notes) > release.GITHUB_RELEASE_BODY_LIMIT + + body = release.release_body(section, repo="aallan/vera") + assert len(body) <= release.GITHUB_RELEASE_BODY_LIMIT + assert body != section.notes + assert "### Fixed" in body + assert "- Lead-in 0." in body + assert "- Lead-in 49." in body + assert filler not in body + assert ( + "https://github.com/aallan/vera/blob/v0.1.5/CHANGELOG.md#015---2026-07-15" + in body + ) + + def test_the_condensed_body_states_the_measured_length_and_the_limit(self) -> None: + section = _section( + "### Fixed\n\n" + "\n".join(f"- **Lead {n}.** {'y' * 4000}" for n in range(50)) + ) + body = release.release_body(section, repo="aallan/vera") + assert f"{len(section.notes):,} characters" in body + assert f"{release.GITHUB_RELEASE_BODY_LIMIT:,}-character" in body + + def test_the_index_reproduces_the_v0110_recovery_shape(self) -> None: + """The lead-in carries the bullet's LAST issue/PR link, wrapped. + + Pinned because the v0.1.10 manual recovery attributed a bullet whose + only reference sat mid-prose (``(PR [#1282](...) review)``), not + immediately after the bold run. + """ + section = _section( + "### Changed\n\n" + "- **Lead one.** Body citing " + "([#1260](https://github.com/aallan/vera/issues/1260)) and then " + "(PR [#1282](https://github.com/aallan/vera/pull/1282) review).\n" + "- **Lead two.** No reference at all.\n" + ) + assert release.condense_notes(section, repo="aallan/vera").splitlines()[-3:] == [ + "### Changed", + "- Lead one. ([#1282](https://github.com/aallan/vera/pull/1282))", + "- Lead two.", + ] + + def test_a_bullet_without_a_bold_lead_in_still_reaches_the_index(self) -> None: + section = _section("### Fixed\n\n- A plain bullet with no bold lead-in.") + assert ( + "- A plain bullet with no bold lead-in." + in release.condense_notes(section, repo="aallan/vera").splitlines() + ) + + def test_condensing_a_bullet_free_section_is_an_error(self) -> None: + """An index that matches nothing is a failure, never a silent empty body.""" + section = release.ChangelogSection("0.1.5", "2026-07-15", "Prose only.") + with pytest.raises(release.ReleaseError, match="no bullets"): + release.condense_notes(section, repo="aallan/vera") + + def test_an_index_that_still_overflows_is_truncated_and_says_so(self) -> None: + bullets = "### Fixed\n\n" + "\n".join( + f"- **{'lead ' * 400}{index}.** detail" for index in range(400) + ) + section = _section(bullets) + assert ( + len(release.condense_notes(section, repo="aallan/vera")) + > release.GITHUB_RELEASE_BODY_LIMIT + ) + + body = release.release_body(section, repo="aallan/vera") + assert len(body) <= release.GITHUB_RELEASE_BODY_LIMIT + assert "truncated" in body + + @pytest.mark.parametrize( + ("version", "date", "expected"), + [ + ("0.1.10", "2026-08-12", "#0110---2026-08-12"), + ("0.1.5", None, "#015"), + ], + ) + def test_changelog_anchor( + self, version: str, date: str | None, expected: str + ) -> None: + assert release.changelog_anchor(version, date) == expected + + def test_every_shipped_changelog_section_yields_a_body_that_fits(self) -> None: + """The real artefact, not a fixture — and non-vacuously. + + v0.1.10's section is the one that 422'd, so at least one section here + must exercise the condensing path; a suite where none did would pass + with the limit check deleted. + """ + root = Path(__file__).parent.parent + text = (root / "CHANGELOG.md").read_text(encoding="utf-8") + # The canonical heading grammar only; the oldest sections carry a + # trailing PR reference the release extractor has never accepted. + versions = re.findall( + r"^## \[(\d+\.\d+\.\d+)\](?: - \d{4}-\d\d-\d\d)?[ \t]*$", + text, + re.MULTILINE, + ) + assert len(versions) > 100, "CHANGELOG version headings no longer found" + + condensed = [] + for version in versions: + section = release.changelog_section(text, version) + body = release.release_body(section, repo="aallan/vera") + assert len(body) <= release.GITHUB_RELEASE_BODY_LIMIT, version + if body != section.notes: + condensed.append(version) + assert "0.1.10" in condensed + class TestPlanning: def test_recovery_tracks_first_parent_bump_and_package_changes( @@ -506,7 +652,11 @@ def plan(mode: str, **kwargs: Any) -> Any: def test_main_notes(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr( - release, "notes_for_version", lambda version: f"- Notes for {version}." + release, + "section_for_version", + lambda version: release.ChangelogSection( + version, "2026-07-15", f"- Notes for {version}." + ), ) output = tmp_path / "release" / "notes.md" assert ( @@ -514,6 +664,47 @@ def test_main_notes(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> No ) assert output.read_text(encoding="utf-8") == "- Notes for 0.1.5.\n" + def test_main_notes_condenses_an_oversized_section( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + notes = "### Fixed\n\n" + "\n".join( + f"- **Lead {index}.** {'z' * 4000}" for index in range(50) + ) + monkeypatch.setattr( + release, + "section_for_version", + lambda version: release.ChangelogSection(version, "2026-07-15", notes), + ) + output = tmp_path / "release" / "notes.md" + assert ( + release.main( + [ + "notes", + "--version", + "0.1.5", + "--output", + str(output), + "--repo", + "aallan/vera", + ] + ) + == 0 + ) + written = output.read_text(encoding="utf-8") + assert len(written) <= release.GITHUB_RELEASE_BODY_LIMIT + assert "- Lead 49." in written + assert "z" * 4000 not in written + + def test_the_release_workflow_passes_the_repository_to_the_notes_step(self) -> None: + """The fix is only real if ``release.yml`` consumes the fitted builder.""" + workflow = ( + Path(__file__).parent.parent / ".github" / "workflows" / "release.yml" + ).read_text(encoding="utf-8") + step = "python scripts/release.py notes" + assert step in workflow, "release.yml no longer invokes the notes builder" + tail = workflow[workflow.index(step) : workflow.index(step) + 400] + assert "--repo" in tail + def test_main_manifest(self, tmp_path: Path) -> None: dist = _dist(tmp_path) output = tmp_path / "release" / "SHA256SUMS" diff --git a/vera/README.md b/vera/README.md index 98bdd1c2..aa189f15 100644 --- a/vera/README.md +++ b/vera/README.md @@ -753,7 +753,7 @@ The `ERROR_CODES` dict in `errors.py` maps every code to a short description (16 ## Test Suite -Testing spans a **pytest suite** of 11,786 tests across 174 files — compiler-internals unit tests plus a **conformance suite** (244 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. +Testing spans a **pytest suite** of 11,907 tests across 175 files — compiler-internals unit tests plus a **conformance suite** (244 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. See **[TESTING.md](../TESTING.md)** for the comprehensive testing reference -- test file table, conformance suite details, compiler code coverage, language feature coverage, helper conventions, validation scripts, CI pipeline, and guidelines for adding tests. From 444df6e99819066e556c8898a872af70495d2f34 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Sat, 15 Aug 2026 01:28:48 +0100 Subject: [PATCH 58/69] Stop matching paths and patterns by their shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows CI was red in all three cells for one reason, and it is the same shape of defect the review found in the grammar gate. `scripts/check_corpus_differential.py`'s `_first_error` stripped the compiled program's path out of a diagnostic by matching `str(path)`, which ties the strip to the host's separator. A diagnostic carrying the POSIX spelling on Windows went unstripped and its absolute path pushed the message past the 160-character truncation. Both spellings are stripped now. The parameter is a `PurePath` rather than a `Path`: nothing there touches the filesystem, and the wider type lets a cell render a Windows path on any host, so the case fails on macOS instead of waiting for the Windows cell. The canary cell asserted the POSIX shape of a root the message renders with the host's separators; it asserts the path it was given, which is the property it meant. The grammar gate had the same shape with worse consequences. `_span_end` read a `/` inside a regex character class as the closing delimiter, so the chapter's `ANNOTATION_COMMENT` — which spells the class `[^/*]` where `vera/grammar.lark` escapes it `[^\/*]` — was truncated mid-pattern. A truncated body is not a bare regex, and `terminal_patterns` skips anything that is not, so that terminal was never compared at all: the gate was green on it by never looking. The scanner now tracks classes, and a drifted `ANNOTATION_COMMENT` is caught rather than skipped. The rest of the review round, each verified against the code first: the differential rejects a non-positive `--timeout`, which would expire before any compile finished and report "no movers" over a corpus that never compiled; it decodes compiler output leniently, a stray byte having otherwise raised `UnicodeDecodeError` out of `subprocess.run` past both handlers and aborted the whole run; it checks the base revision out repository-locally rather than under a predictable shared temporary path whose contents it puts on `PYTHONPATH`; and its reproduction command names the working-tree file it actually compared. `check_doc_counts.py` reads a pytest summary that omits a zero-count category, and its two external calls join the script's own error convention instead of ending the run on a traceback. `check_examples_run.py` restricts the pinned set to the specs it actually opened, so a spec whose file is missing stops drawing the diagnosis for a file that does not exist. `skipped_terminals` closes its group at a blank line, so an unheaded block cannot inherit the waiver. And the chapter's `BLOCK_COMMENT` excludes both delimiters from its character alternative: `{- {- -}` was derivable from a rule describing a construct the implementation rejects as unterminated (E020, measured). One finding is refuted rather than fixed. It reported that `tests/test_grammar_alignment.py`'s alias assertion names a `func_call` that exists in neither file, making the cell unfalsifiable. It does exist: `vera/grammar.lark:229` spells the first `fn_call` alternative `-> func_call`, the extractor lists it among that rule's five aliases, and the mutation that stops `rule_bodies` stripping aliases kills the cell. Its secondary point stands and is taken — an empty body would have satisfied the assertion — so the cell now carries a positive control and checks every alias on the rule. Co-Authored-By: Claude --- .gitignore | 6 + CHANGELOG.md | 1 + FAQ.md | 2 +- README.md | 2 +- RELEASING.md | 4 +- ROADMAP.md | 2 +- TESTING.md | 10 +- docs/llms-full.txt | 2 +- scripts/check_corpus_differential.py | 65 ++++++- scripts/check_doc_counts.py | 67 +++++-- scripts/check_examples_run.py | 15 +- scripts/check_grammar_alignment.py | 39 +++- spec/10-grammar.md | 5 +- tests/test_check_corpus_differential.py | 226 +++++++++++++++++++++++- tests/test_check_doc_counts.py | 51 +++++- tests/test_check_examples_run.py | 54 ++++++ tests/test_grammar_alignment.py | 153 +++++++++++++++- vera/README.md | 2 +- 18 files changed, 647 insertions(+), 59 deletions(-) diff --git a/.gitignore b/.gitignore index 1a9c5f8d..b444b33c 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,9 @@ node_modules/ # and verifies, so it produces neither; `pytest tests/` leaves the tree clean.) /hello.txt /examples/hello.txt + +# `scripts/check_corpus_differential.py` checks the base revision out here +# by default, keyed by SHA and reused across runs. Output, not source, and +# deliberately repository-local rather than under a shared temporary +# directory (its contents end up on the base side's PYTHONPATH). +/.corpus-differential/ diff --git a/CHANGELOG.md b/CHANGELOG.md index b8e1b866..36070cb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **`KNOWN_ISSUES.md`'s Bugs table is gated one row per open `bug` issue.** The structural half is pure text and always on: each row's Issue column must hold exactly one `[#N](…/issues/N)` link whose number matches its URL, no two rows may claim one issue, and an empty section must be written `No known bugs.` rather than left as a bare table. The parity half needs the tracker, and a pre-commit hook must not depend on a network call, so it is opt-in through `scripts/check_doc_counts.py --check-bug-issues` for the release PR — mid-burndown the two legitimately disagree, a bug filed against an open PR's branch having an issue before it has a row. - **TESTING.md's dual-target conformance row is gated against the manifest and a live run.** The row states a run-level total, a tested/skipped split and three category counts, and claims the excluded set "stays accurate as programs are added" — a claim nothing measured. The total now comes from the conformance manifest and the rest from a three-second `-rs` run of the differential itself, with two arithmetic checks the individual figures cannot make: tested plus skipped must be the run-level total, and the three categories must be the skip total. A skip whose reason matches none of the three documented properties fails rather than being folded into one of them. - **`check_examples_run.py` derives which examples need an output sentinel instead of naming them.** The rule was a hard-coded triple — `database`, `file_io`, `sqlitedb` — so a fourth example reaching outside the process could be added with nothing but an exit code asserted, exactly the gap the sentinel exists to close. The set now comes from each program's own declarations: a resource effect in a function's effect row, or a call to a resource operation, read off the parsed AST rather than the source text so a header comment mentioning `` is prose. Both halves are needed, and the measurement said so: `FileIO` and `Time` are not effects in this language — file and clock operations live under `IO` — so `file_io.vera` declares exactly the bare `` that `hello_world.vera` does, and only the operation it calls separates them. What stays hand-written is a short list of registry *names*, and those are validated against the live effect registry, so a renamed or deleted effect or operation fails loudly rather than silently matching no example. The derived set must equal the specs carrying a sentinel in both directions, so a sentinel on an example with no resource signal is an error too. +- **The corpus differential and the grammar gate are hardened against the platform they run on and the patterns they read** (PR #1329 review). `_first_error` stripped the compiled file's path from a diagnostic by matching `str(path)` alone, which ties the strip to the host's separator: on Windows a diagnostic carrying the POSIX spelling went unstripped and its absolute path pushed the message past the truncation. Both spellings are stripped now, and the parameter is a `PurePath` so a test can render a Windows path on any host rather than waiting for the Windows CI cell. The grammar gate's comment scanner had the same shape of defect with worse consequences: a `/` inside a regex character class was read as the closing delimiter, so the chapter's `ANNOTATION_COMMENT` — which spells the class `[^/*]` where the Lark grammar escapes it `[^\/*]` — was truncated, and a truncated body is not a bare regex, so the terminal was **skipped from the pattern comparison entirely**. That gate was green on it by never looking. Both now have cells that fail on any host. Alongside them: the differential rejects a non-positive `--timeout` (which would fail every compile and report "no movers" over a corpus that never compiled), decodes compiler output leniently (a stray byte otherwise raised out of `subprocess.run` and aborted the whole run), checks the base revision out repository-locally rather than under a predictable shared temporary path whose contents it puts on `PYTHONPATH`, and prints a reproduction command that names the same input it actually compared. `check_doc_counts.py` reads a pytest summary that omits a zero-count category, and its two external calls — the dual-target run and the tracker query — join the script's own error convention instead of ending the run on a traceback. The chapter's `BLOCK_COMMENT` production excludes both delimiters from its character alternative, so `{- {- -}` is no longer derivable from a rule describing a construct the implementation rejects as unterminated. - **`scripts/check_corpus_differential.py`** promotes the burndown's ad-hoc corpus differential to a first-class instrument: it compiles every corpus program at two revisions and reports the movers, including the programs that compile on one side only. It is deliberately not a pre-commit hook — it compiles the whole corpus twice — and is documented as a CI-optional burndown instrument. ### Fixed diff --git a/FAQ.md b/FAQ.md index aca7fb1d..5f7e0065 100644 --- a/FAQ.md +++ b/FAQ.md @@ -279,7 +279,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 11,907 tests, including a 244-program conformance suite +- 11,938 tests, including a 244-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/README.md b/README.md index ef355bee..01985c6d 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ cp /path/to/vera/SKILL.md ~/.claude/skills/vera-language/SKILL.md ## Project status -Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 11,907 tests, 95% Python code coverage, 244 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. +Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 11,938 tests, 95% Python code coverage, 244 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. The reference compiler — parser, AST, type checker, contract verifier (Z3), WASM code generator, module system, browser runtime, and runtime contract insertion — is working. The language specification is in draft across [14 chapters](spec/). diff --git a/RELEASING.md b/RELEASING.md index 645660d7..66e87416 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -96,7 +96,9 @@ The release-prep PR must: 4. Reconcile `KNOWN_ISSUES.md`'s Bugs table with the tracker, by running `python scripts/check_doc_counts.py --check-bug-issues`. The convention is one row per open `bug`-labelled issue, and the check needs the GitHub - API, so it is opt-in rather than part of the pre-commit hook: mid-cycle + API — it sends `GH_TOKEN` or `GITHUB_TOKEN` when either is set, and is + rate limited per IP when neither is, so export one before running it — + so it is opt-in rather than part of the pre-commit hook: mid-cycle the two legitimately disagree, since a bug filed against an open PR's branch has an issue before it has a row. At release time they should agree — that is the point at which the file is the published list. diff --git a/ROADMAP.md b/ROADMAP.md index c67b1c3b..ecd01ead 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,7 +12,7 @@ Ordering derives from the design principles ([DESIGN.md](DESIGN.md)): verificati ## Where we are -11,907 tests, 244 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. +11,938 tests, 244 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. ## Stage 19 — The verification completeness sprint diff --git a/TESTING.md b/TESTING.md index 94de32c6..07d34c70 100644 --- a/TESTING.md +++ b/TESTING.md @@ -6,7 +6,7 @@ This is the single source of truth for Vera's testing infrastructure, coverage d | Metric | Value | |--------|-------| -| **Tests** | 11,907 across 175 files (~155,000 lines of test code; 11,708 passed + 26 stress, 173 skipped) | +| **Tests** | 11,938 across 175 files (~155,000 lines of test code; 11,739 passed + 26 stress, 173 skipped) | | **Compiler code coverage** | 95% Python, 87% JavaScript (CI minimum: 80%) | | **Conformance programs** | 244 programs across 9 spec chapters, validating every language feature | | **Example programs** | 42, all validated through `vera check` + `vera verify` | @@ -222,10 +222,10 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_builtin_typevar_collision_970.py` | 61 | 811 | #970 a user `forall` type-var name colliding with a built-in generic's internal name (`T`/`E`/`A`/`B`/`K`/`U`/`V`): focused check/verify pins for the compound-argument shapes (`@Array>`, `@Result>`, `@Map>`) plus a collide-vs-control differential battery over every generic-builtin family and contract/where-helper position. Also pins marker-strip (the `#b` namespacing marker must never reach an E205/E202 diagnostic), a registry-consistency pin (every built-in ability-constraint `type_var` stays a member of its `forall_vars`), the dual completeness-gap pinned in both argument orders, a tier-split equality pin, and the #1069 leaked-placeholder message-rendering sweep (a stripped built-in var renders as `?`, not a bare letter, at every reachable actual-type slot: the mismatch sites plus the operator/index/interpolation family, `assert`/`assume`, `if` condition and branches, and the contract/refinement predicates — one parametrized row per converted render slot, with the provably-unreachable sites documented in the class docstring) | | `test_check_changelog_updated.py` | 68 | 712 | `check_changelog_updated.py` unit + end-to-end tests: file classification (incl. file-style exact-match vs directory-style prefix-match), CHANGELOG diff parsing with `[Unreleased]` section tracking, bare-heading rejection, and full-file context (regression test for bullets far below the heading), `Skip-changelog:` trailer detection, temp-repo integration covering substantive/exempt/label/trailer paths, and `GIT_*`-env hermeticity of the temp-repo fixtures (regression for the pre-commit-hook env leak) | | `test_release.py` | 61 | 757 | Release policy and registry verification (#481): strict project-name and version parsing/comparison, version-bump/TestPyPI/recovery planning, exact confirmation and immutable-tag guards, first-parent version-introduction discovery, package-change recovery refusal, non-empty CHANGELOG extraction, one-wheel/one-sdist SHA-256 manifests, malformed registry-response handling, missing/filename/hash propagation retries, exact filename/hash verification, and CLI dispatch/GitHub-output wiring. An autouse fixture scrubs hook-exported `GIT_*` variables so the tmp-repo git calls (fixture helpers and `release.py`'s own) never resolve to the developer's repository when the suite runs inside a pre-commit hook. | -| `test_check_doc_counts.py` | 87 | 943 | `check_doc_counts.py`'s pure per-document checks: KNOWN_ISSUES refactoring line counts (±10% tolerance band incl. the exact-boundary case, drift detection, empty-file citation, hyphenated paths, missing file/section/rows, the #419 empty-section sentinel + its cannot-mask-a-malformed-table dual), HISTORY version-row format (issue-link limit, ` — ` separator rejection, dateless-row and prose exemption, line-number reporting), the TESTING.md tests breakdown (parts summing to the collected total, a self-consistent-but-stale row, and the reworded-row error), and vera/README.md's Test Suite counts (all four checked independently — mutation-validated by dropping each citation in turn — plus the reworded-paragraph error, a thousands-separator case pinning that every one of the four counts is read comma-tolerantly, and the two section-anchoring cases — a reworded paragraph with decoy counts in a later section, and a renamed heading, both of which must fail loud rather than match across the section boundary). The reworded case is a test in its own right for both new checks: a pattern that matches nothing must be an error, or rewording the sentence silently switches the gate off. Also the release count (README's status line and HISTORY's total against each other and against `git tag`: the matching case, the one-ahead release cut that `release.yml` has not tagged yet, that +1 being the ONLY slack once the version is tagged, the two-behind drift that actually shipped, per-document reporting, and a tagless checkout standing the oracle down without standing down the cross-check), plus the tag reader itself against real repositories built in `tmp_path` — release tags read, `nightly`/`-rc1` not counted as releases, and both no-evidence answers (`None` rather than `[]`, since an empty list would read as zero releases and make every documented count wrong) for a tagless checkout and a directory that is not a repository at all. CONTRIBUTING.md's pre-commit hook count is checked the same way, reworded-sentence case included; and the CI-pipeline lint row against `ci.yml`'s lint job — a matching row, a step present in CI but absent from the row (the drift that shipped), a row entry CI no longer runs, the same set in a different order, a reworded row and a renamed job (both errors, not skips), that only the lint job is read rather than the whole workflow, and the shipped pair both clean and red with one entry dropped | -| `test_grammar_alignment.py` | 74 | 533 | `check_grammar_alignment.py` gate ([#683](https://github.com/aallan/vera/issues/683)): both extractors (Lark headers with the `?`/`!`/`_` markers stripped, template parameters and rule priorities tolerated, and `-> alias` names deliberately not collected; spec headers from ```ebnf fences only), the allowlist arithmetic in all three directions — unwaived drift, a spent entry both files now have, and an entry whose premise broke — one case per waiver proving the fact it rests on is actually checked, the name-deleted-from-both-files case that must not read as agreement, a mutation restoring the spec's old `assert_stmt` name, non-vacuous extraction, and the false positive the issue itself rested on: `qualified_call` and `module_call` are spec headers Lark expresses as aliases, and must never be reported as drift. Three pin the premise checks against ways they used to pass vacuously: an alias surviving only inside a `//` comment must not hold its waiver up, an alias that moved to another rule must fail the waiver naming `fn_call`, and a spent waiver whose premise also broke must yield one instruction rather than two opposite ones | -| `test_check_examples_run.py` | 75 | 1,226 | `check_examples_run.py`, the harness gate that RUNS the examples. Five separable parts, each in both directions. **The coverage rule** — the shipped tables cover the shipped corpus exactly, and an unclassified example, a stale `RUN_SPECS` or `SKIPS` key whose file is gone, a name in both tables, and a skip citing an undocumented property are each an error; the empty corpus is an error too, since a glob that stops matching would otherwise report success over zero programs. Plus the specs' own well-formedness: every named entry point is `public` in its example, every no-main example pins one (or `vera run` would fall back to an arbitrary first export), and no skip property is unused. **The runner** — a seeded `tmp_path` corpus proves it goes red on a program that type-checks and compiles but traps at run time, green on one that does not, and reports only the broken member of a mixed pair; a fixture whose first export is clean and whose named one traps proves `spec.fn` is actually honoured rather than ignored; a writer program proves each run gets a scratch working directory, so a gate run leaves nothing beside the examples. **The TESTING.md cross-check** — missing row, extra row, rename (reported naming both sides), wrong disposition and wrong skip property are errors, the parse stops at the next heading so a row-shaped line in a later section is not swept in, and both a reworded heading and a heading whose table has vanished fail loud rather than finding nothing to compare. **The output signal** -- the second half of the two-signal discipline `check_examples.py` established: the fallback note that `vera run` prints when it cannot use the named entry point is a failure even at exit 0, an absent `expect` sentinel is a failure even at exit 0, and a spec without one asserts nothing about output; end to end, a privatised `main` and a program that completed down a graceful arm each go red, and the same program passes once its own output is the sentinel, so the check reads the output rather than always failing. Which specs must carry a sentinel is **derived** from what each example declares — a resource effect in a function's effect row, or a call to a resource operation, both validated against the live effect registry so a renamed effect or op fails loudly — and asserted equal to the specs that have one, in both directions; the previous hard-coded triple could not see a fourth such example arriving without one. The runner's use of BOTH streams is pinned structurally -- `vera run` writes the note to stderr and nothing there on a clean exit, so no fixture can distinguish reading both streams from reading stdout alone, and a tripwire wired to the wrong stream is no tripwire. Also the hermetic-environment property: an ambient `VERA_DB_URL` or provider key is stripped so a gate run cannot be pointed at a real database or turned into a billed API call, a fixture spec puts its own URL back, and every neutralised name is checked to be one `vera/runtime/` actually reads | -| `test_check_corpus_differential.py` | 28 | 498 | `check_corpus_differential.py`, the burndown instrument that compiles the corpus at two revisions. The pure pieces only — the real two-revision run costs minutes and is not a test. **Classification**, all four verdicts: identical, WAT differs, and each one-sided compile failure as its own kind, since a compilability reversal reported as a text difference is the mis-description the instrument exists to avoid; failing at both revisions is not a mover and is counted separately, so a green run states how much of it was vacuous. **Enumeration** — recursive, keyed by repo-relative POSIX path, and an empty corpus is an error rather than a clean run over nothing. **The canary** — each side must import the compiler it was pointed at, so a side silently resolving to the venv's editable install cannot compare a revision against itself; an import failure and a foreign compiler are different messages. **Reporting** — every mover named with its reason, the exit code, the `--json` shape, and a program missing from one side reported rather than dropped. One test asserts the instrument is absent from `.pre-commit-config.yaml`, so the docstring's claim cannot rot | +| `test_check_doc_counts.py` | 89 | 986 | `check_doc_counts.py`'s pure per-document checks: KNOWN_ISSUES refactoring line counts (±10% tolerance band incl. the exact-boundary case, drift detection, empty-file citation, hyphenated paths, missing file/section/rows, the #419 empty-section sentinel + its cannot-mask-a-malformed-table dual), HISTORY version-row format (issue-link limit, ` — ` separator rejection, dateless-row and prose exemption, line-number reporting), the TESTING.md tests breakdown (parts summing to the collected total, a self-consistent-but-stale row, and the reworded-row error), and vera/README.md's Test Suite counts (all four checked independently — mutation-validated by dropping each citation in turn — plus the reworded-paragraph error, a thousands-separator case pinning that every one of the four counts is read comma-tolerantly, and the two section-anchoring cases — a reworded paragraph with decoy counts in a later section, and a renamed heading, both of which must fail loud rather than match across the section boundary). The reworded case is a test in its own right for both new checks: a pattern that matches nothing must be an error, or rewording the sentence silently switches the gate off. Also the release count (README's status line and HISTORY's total against each other and against `git tag`: the matching case, the one-ahead release cut that `release.yml` has not tagged yet, that +1 being the ONLY slack once the version is tagged, the two-behind drift that actually shipped, per-document reporting, and a tagless checkout standing the oracle down without standing down the cross-check), plus the tag reader itself against real repositories built in `tmp_path` — release tags read, `nightly`/`-rc1` not counted as releases, and both no-evidence answers (`None` rather than `[]`, since an empty list would read as zero releases and make every documented count wrong) for a tagless checkout and a directory that is not a repository at all. CONTRIBUTING.md's pre-commit hook count is checked the same way, reworded-sentence case included; and the CI-pipeline lint row against `ci.yml`'s lint job — a matching row, a step present in CI but absent from the row (the drift that shipped), a row entry CI no longer runs, the same set in a different order, a reworded row and a renamed job (both errors, not skips), that only the lint job is read rather than the whole workflow, and the shipped pair both clean and red with one entry dropped | +| `test_grammar_alignment.py` | 85 | 672 | `check_grammar_alignment.py` gate ([#683](https://github.com/aallan/vera/issues/683)): both extractors (Lark headers with the `?`/`!`/`_` markers stripped, template parameters and rule priorities tolerated, and `-> alias` names deliberately not collected; spec headers from ```ebnf fences only), the allowlist arithmetic in all three directions — unwaived drift, a spent entry both files now have, and an entry whose premise broke — one case per waiver proving the fact it rests on is actually checked, the name-deleted-from-both-files case that must not read as agreement, a mutation restoring the spec's old `assert_stmt` name, non-vacuous extraction, and the false positive the issue itself rested on: `qualified_call` and `module_call` are spec headers Lark expresses as aliases, and must never be reported as drift. Three pin the premise checks against ways they used to pass vacuously: an alias surviving only inside a `//` comment must not hold its waiver up, an alias that moved to another rule must fail the waiver naming `fn_call`, and a spent waiver whose premise also broke must yield one instruction rather than two opposite ones | +| `test_check_examples_run.py` | 77 | 1,280 | `check_examples_run.py`, the harness gate that RUNS the examples. Five separable parts, each in both directions. **The coverage rule** — the shipped tables cover the shipped corpus exactly, and an unclassified example, a stale `RUN_SPECS` or `SKIPS` key whose file is gone, a name in both tables, and a skip citing an undocumented property are each an error; the empty corpus is an error too, since a glob that stops matching would otherwise report success over zero programs. Plus the specs' own well-formedness: every named entry point is `public` in its example, every no-main example pins one (or `vera run` would fall back to an arbitrary first export), and no skip property is unused. **The runner** — a seeded `tmp_path` corpus proves it goes red on a program that type-checks and compiles but traps at run time, green on one that does not, and reports only the broken member of a mixed pair; a fixture whose first export is clean and whose named one traps proves `spec.fn` is actually honoured rather than ignored; a writer program proves each run gets a scratch working directory, so a gate run leaves nothing beside the examples. **The TESTING.md cross-check** — missing row, extra row, rename (reported naming both sides), wrong disposition and wrong skip property are errors, the parse stops at the next heading so a row-shaped line in a later section is not swept in, and both a reworded heading and a heading whose table has vanished fail loud rather than finding nothing to compare. **The output signal** -- the second half of the two-signal discipline `check_examples.py` established: the fallback note that `vera run` prints when it cannot use the named entry point is a failure even at exit 0, an absent `expect` sentinel is a failure even at exit 0, and a spec without one asserts nothing about output; end to end, a privatised `main` and a program that completed down a graceful arm each go red, and the same program passes once its own output is the sentinel, so the check reads the output rather than always failing. Which specs must carry a sentinel is **derived** from what each example declares — a resource effect in a function's effect row, or a call to a resource operation, both validated against the live effect registry so a renamed effect or op fails loudly — and asserted equal to the specs that have one, in both directions; the previous hard-coded triple could not see a fourth such example arriving without one. The runner's use of BOTH streams is pinned structurally -- `vera run` writes the note to stderr and nothing there on a clean exit, so no fixture can distinguish reading both streams from reading stdout alone, and a tripwire wired to the wrong stream is no tripwire. Also the hermetic-environment property: an ambient `VERA_DB_URL` or provider key is stripped so a gate run cannot be pointed at a real database or turned into a billed API call, a fixture spec puts its own URL back, and every neutralised name is checked to be one `vera/runtime/` actually reads | +| `test_check_corpus_differential.py` | 44 | 710 | `check_corpus_differential.py`, the burndown instrument that compiles the corpus at two revisions. The pure pieces only — the real two-revision run costs minutes and is not a test. **Classification**, all four verdicts: identical, WAT differs, and each one-sided compile failure as its own kind, since a compilability reversal reported as a text difference is the mis-description the instrument exists to avoid; failing at both revisions is not a mover and is counted separately, so a green run states how much of it was vacuous. **Enumeration** — recursive, keyed by repo-relative POSIX path, and an empty corpus is an error rather than a clean run over nothing. **The canary** — each side must import the compiler it was pointed at, so a side silently resolving to the venv's editable install cannot compare a revision against itself; an import failure and a foreign compiler are different messages. **Reporting** — every mover named with its reason, the exit code, the `--json` shape, and a program missing from one side reported rather than dropped. One test asserts the instrument is absent from `.pre-commit-config.yaml`, so the docstring's claim cannot rot | | `test_check_editor_grammars.py` | 20 | 249 | `check_editor_grammars.py` gate ([#1156](https://github.com/aallan/vera/issues/1156)): the registry read (every effect in, every ability out, and the four names the grammars actually drifted on present so the set checked is non-vacuous), the word-boundary presence test across all three grammar formats (JSON, plist XML, Vim keyword list) including the prefix pair `Http`/`HttpServer` in both directions, the deliberate comment-mention false pass, a metacharacter pair that only passes when the name is matched literally (`A.B` present, `A0B` not), and the empty registry; the gate's primary path end to end — a listed grammar with an effect stripped out, and a listed README with one stripped out of its prose bullet, each red against an otherwise-clean mirrored tree; the completeness guard — a grammar discovered under `editors/` but absent from `GRAMMARS` fails that same tree, over three discovery routes (`.el` outside a syntax directory, a tree-sitter `.scm` query set, a `.tmLanguage.json` filed anywhere but `syntaxes/`); and the registry's provenance, run as a subprocess against a throwaway checkout whose `vera` package names an effect the grammars do not, which is the only way to see that the list comes from the tree being checked rather than from site-packages. The shipped grammars and READMEs are currently clean | | `test_check_explicit_encoding.py` | 54 | 254 | `check_explicit_encoding.py` gate (#645): flags text-mode `open()` / `read_text()` / `write_text()` **and** `subprocess.run/Popen/check_output(..., text=True)` captures missing an `encoding="utf-8"` literal (rejects non-literal / non-UTF-8 values), skips binary/bytes-mode calls, honours the `# encoding-exempt` opt-out, and asserts the shipped repo is clean | | `test_check_limitations_sync.py` | 6 | 108 | `check_limitations_sync.py` section extraction: table-rows-only issue harvesting, prose-link exemption, bounding at the next second-level heading, `None` for absent or sub-level headings so renamed sections fail loudly; plus the #852 fail-loud rule: an UNKNOWN issue state under `--check-states` (gh missing / auth failure / timeout) is an error, never a silent pass | diff --git a/docs/llms-full.txt b/docs/llms-full.txt index c1cdaa02..f8d26c31 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -3265,7 +3265,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 11,907 tests, including a 244-program conformance suite +- 11,938 tests, including a 244-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/scripts/check_corpus_differential.py b/scripts/check_corpus_differential.py index 29349807..1a6d9847 100644 --- a/scripts/check_corpus_differential.py +++ b/scripts/check_corpus_differential.py @@ -92,10 +92,9 @@ import re import subprocess import sys -import tempfile from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor -from pathlib import Path +from pathlib import Path, PurePath from typing import NamedTuple @@ -106,6 +105,31 @@ # `scripts/check_corpus_canonical.py` records having had. _CORPUS_DIRS = ("examples", "tests/conformance") +# The base checkout's default home. Repository-local rather than under +# `tempfile.gettempdir()`: the path is fully predictable (a fixed directory +# name plus a public commit SHA), `base_checkout` reuses a pre-existing +# directory, and `_side_env` then puts it on PYTHONPATH — so on a shared +# machine another local user could plant a `vera` package there and the base +# side would import it. The canary cannot object, because the planted +# package sits under the expected root (#1329 review). +_DEFAULT_WORK_DIR = Path(__file__).resolve().parent.parent / ".corpus-differential" + + +def _positive_seconds(value: str) -> int: + """An `argparse` type for a budget that must be able to elapse. + + Zero or negative expires before any compile finishes, so both sides + fail every program, `compare` counts them all as `both_failed`, and + the run reports "No movers" over a corpus that never compiled. + """ + seconds = int(value) + if seconds <= 0: + raise argparse.ArgumentTypeError( + f"--timeout must be greater than zero, not {seconds}" + ) + return seconds + + # Per-file compile budget. Generous — a corpus program compiles in well # under a second — so this only fires on a genuine hang, and a hang on # one side is reported as that side failing rather than blocking the run. @@ -356,7 +380,7 @@ def probe_compiler(python: str, root: Path) -> str: return result.stdout if result.returncode == 0 else "" -def _first_error(stderr: str, path: Path) -> str: +def _first_error(stderr: str, path: PurePath) -> str: """The compile's reason, in one line. A Vera diagnostic is a *block* — marker line, quoted source, caret, @@ -375,6 +399,14 @@ def _first_error(stderr: str, path: Path) -> str: already attached to a named program, and a corpus file's absolute path under a scratch checkout is long enough on its own to push the diagnostic past the truncation. + + Both spellings of that path are stripped. Matching on ``str(path)`` + alone ties the strip to the host's separator, and a diagnostic is + free to print the POSIX form on Windows — whereupon the strip + matches nothing, silently, and the truncation eats the message + instead of the path. The parameter is a ``PurePath`` rather than a + ``Path`` for the same reason: nothing here touches the filesystem, + and the wider type lets a test render a Windows path on any host. """ lines = [line.strip() for line in stderr.splitlines()] nonempty = [line for line in lines if line] @@ -386,7 +418,9 @@ def _first_error(stderr: str, path: Path) -> str: else: reason = nonempty[-1] if nonempty else "compile failed with no output" - return reason.replace(str(path), path.name)[:160] + for rendering in (str(path), path.as_posix()): + reason = reason.replace(rendering, path.name) + return reason[:160] def compile_one( @@ -406,6 +440,13 @@ def compile_one( capture_output=True, text=True, encoding="utf-8", + # A compiler is free to emit a byte this codec cannot read, and + # strict decoding would raise UnicodeDecodeError out of + # `subprocess.run` — a ValueError that neither handler below + # catches, aborting the whole corpus run through + # `ThreadPoolExecutor.map`. An undecodable diagnostic is data + # like any other failure (#1329 review). + errors="replace", cwd=str(compiler_root), env=_side_env(compiler_root), stdin=subprocess.DEVNULL, @@ -581,7 +622,14 @@ def failure_lines(info: RunInfo, comparison: Comparison) -> list[str]: "it moved. Reproduce one with:", "", " vera compile --wat # working tree", - f" (cd {info.base_root} && vera compile --wat )", + f" (cd {info.base_root} && vera compile --wat " + f"{info.head_root}/)", + "", + " is the mover's path above, and BOTH commands compile " + "the working tree's copy of it — that is what the differential " + "compared. A relative path in the second command would compile " + "the base checkout's own copy instead, which is a different " + "input whenever the corpus source has changed.", ] if comparison.unreported: if lines: @@ -640,16 +688,17 @@ def _parse_args(argv: list[str] | None) -> argparse.Namespace: ) parser.add_argument( "--work-dir", - default=str(Path(tempfile.gettempdir()) / "vera-corpus-differential"), + default=str(_DEFAULT_WORK_DIR), help="where the base revision is checked out; the checkout is " - "keyed by SHA, reused, and never deleted", + "keyed by SHA, reused, and never deleted " + "(default: %(default)s)", ) parser.add_argument( "--jobs", type=int, default=min(8, os.cpu_count() or 1), help="parallel compiles per side (default: %(default)s)", ) parser.add_argument( - "--timeout", type=int, default=DEFAULT_TIMEOUT_SECONDS, + "--timeout", type=_positive_seconds, default=DEFAULT_TIMEOUT_SECONDS, help="per-program compile budget in seconds (default: %(default)s)", ) parser.add_argument( diff --git a/scripts/check_doc_counts.py b/scripts/check_doc_counts.py index ce7d9e88..24f18cf5 100644 --- a/scripts/check_doc_counts.py +++ b/scripts/check_doc_counts.py @@ -914,7 +914,12 @@ class DualTargetSplit(NamedTuple): ("nondeterministic", "nondeterministic ops"), ) _SKIP_LINE = re.compile(r"^SKIPPED \[(\d+)\] (.*)$", re.M) -_PYTEST_SUMMARY = re.compile(r"(\d+) passed, (\d+) skipped") +# pytest omits a category with a zero count, so "174 passed in 3.1s" and +# "52 skipped in 3.1s" are both well-formed summaries. A pattern +# requiring both made either one unreadable, and an unreadable report is +# a gate failure — a false one (#1329 review). +_PYTEST_TOTALS = re.compile(r"(\d+) (passed|skipped)\b") +_PYTEST_SUMMARY = re.compile(r"\d+ (?:passed|skipped)\b[^\n]*\bin [\d.]+s") _DUAL_TARGET_FIGURES = ( ("tested", r"(\d+) are dual-tested"), ("skipped", r"and (\d+) skip"), @@ -935,6 +940,7 @@ def parse_dual_target_report(report: str) -> DualTargetSplit | None: summary = _PYTEST_SUMMARY.search(report) if summary is None: return None + totals = {kind: int(n) for n, kind in _PYTEST_TOTALS.findall(summary.group(0))} counts = dict.fromkeys((name for name, _ in _SKIP_REASONS), 0) for raw, reason in _SKIP_LINE.findall(report): for name, marker in _SKIP_REASONS: @@ -943,10 +949,10 @@ def parse_dual_target_report(report: str) -> DualTargetSplit | None: break else: return None - skipped = int(summary.group(2)) + skipped = totals.get("skipped", 0) if sum(counts.values()) != skipped: return None - return DualTargetSplit(int(summary.group(1)), skipped, **counts) + return DualTargetSplit(totals.get("passed", 0), skipped, **counts) def dual_target_split(root: Path) -> DualTargetSplit | None: @@ -954,15 +960,23 @@ def dual_target_split(root: Path) -> DualTargetSplit | None: pytest_bin = root / ".venv/bin/pytest" if not pytest_bin.exists(): pytest_bin = Path("pytest") - result = subprocess.run( - [str(pytest_bin), _DUAL_TARGET_TEST, "-q", "-rs", "-p", "no:randomly"], - capture_output=True, - text=True, - encoding="utf-8", - cwd=str(root), - timeout=300, - check=False, - ) + try: + result = subprocess.run( + [str(pytest_bin), _DUAL_TARGET_TEST, "-q", "-rs", "-p", "no:randomly"], + capture_output=True, + text=True, + encoding="utf-8", + cwd=str(root), + timeout=300, + check=False, + ) + except (OSError, subprocess.SubprocessError): + # Every other check here turns a failure into a string in `errors` + # and lets `main` print the whole list. Letting this one raise + # would end the run on a traceback and the other twenty checks + # would never report — and this call is on the default path, so + # the pre-commit hook takes it every time (#1329 review). + return None if result.returncode != 0: return None return parse_dual_target_report(result.stdout) @@ -1102,8 +1116,19 @@ def check_bug_issue_parity(rows: list[int], open_bugs: list[int]) -> list[str]: return errors +class BugQueryError(RuntimeError): + """The tracker could not be queried — a failed run, not an empty one.""" + + def open_bug_issues(repo: str = "aallan/vera") -> list[int]: - """Open issue numbers carrying the `bug` label, from the GitHub API.""" + """Open issue numbers carrying the `bug` label, from the GitHub API. + + Raises `BugQueryError` rather than returning `[]` on a transport or + payload failure. `check_bug_issue_parity` reads an empty list as + "the query failed", so returning one here would reach the right + verdict for the wrong reason — and the caller could no longer tell a + burned-down tracker from an unreachable one (#1329 review). + """ numbers: list[int] = [] for page in range(1, 11): url = ( @@ -1114,9 +1139,14 @@ def open_bug_issues(repo: str = "aallan/vera") -> list[int]: token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") if token: request.add_header("Authorization", f"Bearer {token}") - # The URL is built from a caller-supplied repository, never from input. - with urlopen(request, timeout=30) as response: - payload = json.load(response) + try: + # The URL is built from a caller-supplied repository, not input. + with urlopen(request, timeout=30) as response: + payload = json.load(response) + except (OSError, ValueError) as exc: + # URLError and HTTPError are OSError; a socket timeout is too, + # and a malformed body raises JSONDecodeError, a ValueError. + raise BugQueryError(f"could not query {repo} for open bugs: {exc}") from exc if not payload: break numbers += [ @@ -1763,7 +1793,10 @@ def check_testing(pattern: str, expected: int, label: str) -> None: if args.check_bug_issues: rows = bug_rows(known_issues) if rows is not None: - errors.extend(check_bug_issue_parity(rows, open_bug_issues())) + try: + errors.extend(check_bug_issue_parity(rows, open_bug_issues())) + except BugQueryError as exc: + errors.append(f"KNOWN_ISSUES.md: {exc}") # ------------------------------------------------------------------ # Report diff --git a/scripts/check_examples_run.py b/scripts/check_examples_run.py index b9f96f91..15c1bc81 100644 --- a/scripts/check_examples_run.py +++ b/scripts/check_examples_run.py @@ -470,6 +470,7 @@ def check_sentinel_coverage( return errors signals_by_name: dict[str, frozenset[str]] = {} + inspected: set[str] = set() for name in sorted(run_specs): path = examples_dir / f"{name}.vera" if not path.is_file(): @@ -479,6 +480,7 @@ def check_sentinel_coverage( # the files gone the derivation is empty, which is the # error below. continue + inspected.add(name) try: signals = resource_signals(path) except Exception as exc: # noqa: BLE001 — unknown signals are reported, never read as none @@ -507,7 +509,18 @@ def check_sentinel_coverage( f"zero examples." ] - pinned = {name for name, spec in run_specs.items() if spec.expect} + # Only the specs whose `.vera` the loop above actually read. Building + # this from every entry in `run_specs` put a spec whose file is missing + # into `pinned - signals_by_name`, where it drew the spurious-sentinel + # diagnosis — "declares no external resource" — when the truth is that + # the derivation never opened it. `check_coverage` reports the missing + # file first in `main`, but this is a public function tests call + # directly (#1329 review). + pinned = { + name + for name, spec in run_specs.items() + if spec.expect and name in inspected + } for name in sorted(set(signals_by_name) - pinned): errors.append( f"{name}.vera reaches outside the process " diff --git a/scripts/check_grammar_alignment.py b/scripts/check_grammar_alignment.py index 5480fa56..f27e1144 100644 --- a/scripts/check_grammar_alignment.py +++ b/scripts/check_grammar_alignment.py @@ -142,13 +142,39 @@ def strip_comment(line: str) -> str: def _span_end(line: str, start: int, quote: str) -> int | None: - """Index just past the literal or regex opened at ``start``, or ``None``.""" + """Index just past the literal or regex opened at ``start``, or ``None``. + + Inside a regex, a ``/`` within a ``[…]`` character class is a member + and not the closing delimiter. Ignoring that ended the scan inside + the class of the chapter's annotation-comment terminal — which + spells it ``[^/*]`` where the Lark grammar escapes it ``[^\\/*]`` — + truncating the declaration. A truncated body is not a bare regex, + so ``terminal_patterns`` skipped that terminal altogether: green + because nothing was compared, the failure this gate exists to catch + (#1329). A ``]`` in the first position of a class is a member too, + which is why the class is not closed until at least one has been + consumed. + """ index = start + 1 + in_class = False + class_start = -1 while index < len(line): - if line[index] == "\\": + char = line[index] + if char == "\\": index += 2 continue - if line[index] == quote: + if quote == "/" and not in_class and char == "[": + in_class = True + class_start = index + index += 1 + continue + if in_class: + first = class_start + (2 if line[class_start + 1 : class_start + 2] == "^" else 1) + if char == "]" and index > first: + in_class = False + index += 1 + continue + if char == quote: return index + 1 index += 1 return None @@ -397,6 +423,13 @@ def skipped_terminals(lines: list[str]) -> set[str]: for raw in lines: stripped = raw.strip() if not stripped: + # The blank ENDS the group as well as opening a new block. A + # declaration block with no heading of its own would otherwise + # inherit whatever the previous block was, silently widening + # the waiver past the terminals the marker names (#1329 + # review). §10.2 has no blank inside the skipped group today, + # so this narrows the rule without moving the current result. + in_group = False at_block_start = True continue if stripped.startswith("//"): diff --git a/spec/10-grammar.md b/spec/10-grammar.md index 6b6ffc86..20de2afe 100644 --- a/spec/10-grammar.md +++ b/spec/10-grammar.md @@ -24,7 +24,10 @@ LINE_COMMENT: /--[^\n]*/ // Block comments nest (Section 1.3), so they are not a regular language and // have no regex form. The reference implementation removes them in // vera/lexical.py, by counting depth, before the parser sees the text. -BLOCK_COMMENT: "{-" (BLOCK_COMMENT | /[\s\S]/)* "-}" +// The character alternative excludes both delimiters, so a `{-` inside the +// body opens a nested comment and must be closed: `{- {- -}` is not a +// block comment, and the implementation reports it unterminated (E020). +BLOCK_COMMENT: "{-" (BLOCK_COMMENT | /(?!\{-|-\})[\s\S]/)* "-}" ANNOTATION_COMMENT: /\/\*[^*]*\*+([^/*][^*]*\*+)*\// // Keywords diff --git a/tests/test_check_corpus_differential.py b/tests/test_check_corpus_differential.py index df46730a..225cf1f1 100644 --- a/tests/test_check_corpus_differential.py +++ b/tests/test_check_corpus_differential.py @@ -28,11 +28,17 @@ from __future__ import annotations +import argparse import importlib.util import json -from pathlib import Path +import subprocess +import sys +import threading +from pathlib import Path, PureWindowsPath from typing import Any +import pytest + _SCRIPT = ( Path(__file__).parent.parent / "scripts" / "check_corpus_differential.py" ) @@ -287,8 +293,11 @@ def test_the_both_failed_count_is_reported_not_hidden( run knows how much of it was measured.""" _MOD.emit(_info(), self._clean(), as_json=False) out = capsys.readouterr().out - assert "1" in out - assert "neither revision" in out + # The whole line, not just the digit: `identical WAT: 1` and the + # run's SHA both contain a "1", so a bare `"1" in out` stayed green + # on a regression that printed `compiled at neither revision: 0` + # (#1329 review). + assert "compiled at neither revision: 1" in out def test_an_unreported_program_exits_one(self, capsys: Any) -> None: """A truncated run is a failed run, not a clean one — even with @@ -354,15 +363,29 @@ def test_a_compiler_under_the_expected_root_passes(self) -> None: ) is None def test_a_compiler_outside_the_expected_root_is_an_error(self) -> None: + root = Path("/scratch/base") message = _MOD.canary_error( - "/usr/lib/site-packages/vera/__init__.py", - Path("/scratch/base"), - "base", + "/usr/lib/site-packages/vera/__init__.py", root, "base" ) assert message is not None assert "base" in message assert "/usr/lib/site-packages/vera/__init__.py" in message - assert "/scratch/base" in message + # The root is asserted by the property "it is this path", not by a + # POSIX shape: the message renders it with the host's separators, + # and `\scratch\base` is the correct rendering on Windows. + assert str(root) in message + assert "different checkout" in message + + def test_the_root_in_the_message_is_the_root_it_was_given(self) -> None: + """Non-vacuity for the assertion above: `str(root) in message` + would also hold if the message quoted some other path that + happened to contain it, so a different root must change it.""" + elsewhere = _MOD.canary_error( + "/usr/lib/site-packages/vera/__init__.py", Path("/other/root"), "base" + ) + assert elsewhere is not None + assert str(Path("/other/root")) in elsewhere + assert str(Path("/scratch/base")) not in elsewhere def test_an_import_failure_is_an_error_that_says_so(self) -> None: """A side that could not import `vera` at all reports no path; @@ -463,6 +486,30 @@ def test_the_compiled_files_path_is_not_repeated_in_the_reason( assert "/repo/x.vera" not in reason assert "x.vera" in reason + def test_a_posix_form_path_is_stripped_under_a_windows_renderer(self) -> None: + """The diagnostic's spelling of the path need not be the host's. + + Stripping on ``str(path)`` alone is a separator-shaped match: on + Windows the same path renders `\\repo\\x.vera`, so a diagnostic + carrying the POSIX form goes unstripped and its absolute path + pushes the message past the truncation — the silent + matches-nothing failure, not a loud one. ``PureWindowsPath`` + reproduces that rendering on any host, so this cell fails on + macOS too rather than only in the Windows CI cell. + """ + stderr = "[E154] Error at /repo/x.vera, line 9, column 8:\n" + reason = _MOD._first_error(stderr, PureWindowsPath("/repo/x.vera")) + assert "/repo/x.vera" not in reason + assert "x.vera" in reason + assert "[E154]" in reason + + def test_a_native_form_path_is_stripped_under_a_windows_renderer(self) -> None: + """The complement: the same path as Windows itself would print it.""" + stderr = "[E154] Error at \\repo\\x.vera, line 9, column 8:\n" + reason = _MOD._first_error(stderr, PureWindowsPath("/repo/x.vera")) + assert "\\repo\\x.vera" not in reason + assert "x.vera" in reason + def test_a_crash_reports_its_exception_not_its_first_line(self) -> None: """No diagnostic marker at all — a compiler crash. The useful line is the exception, which is last.""" @@ -496,3 +543,168 @@ def test_the_instrument_is_not_wired_into_pre_commit(self) -> None: encoding="utf-8" ) assert "check_corpus_differential" not in config + + +class TestParallelCollection: + """The `jobs > 1` branch, which no cell reached (#1329 review). + + Every other collection cell runs at the default `jobs=1`, so the + sequential branch was covered and the parallel one was not. The + parallel branch pairs keys with results *positionally* — it zips a + list built from `files` against `ThreadPoolExecutor.map`'s output — + so it is correct only while `map` yields in input order. If that + ever stopped holding, every artifact would be attributed to the + wrong program and the run would invent movers out of nothing, which + is the one failure this instrument must not have. + """ + + def test_results_stay_paired_with_their_keys(self) -> None: + root = Path("/repo") + files = [root / "examples" / f"p{index}.vera" for index in range(24)] + + def fake_compile(path: Path) -> Any: + return _ok(f"digest-of-{path.name}") + + results = _MOD.collect(files, root, fake_compile, jobs=4) + + assert len(results) == len(files) + for path in files: + assert results[f"examples/{path.name}"].digest == f"digest-of-{path.name}" + + def test_the_parallel_branch_is_the_one_being_exercised(self) -> None: + """Non-vacuity: `jobs=4` must not quietly fall through to the + sequential path, or this class tests nothing new.""" + root = Path("/repo") + files = [root / "examples" / f"p{index}.vera" for index in range(8)] + threads: set[int] = set() + + def fake_compile(path: Path) -> Any: + threads.add(threading.get_ident()) + return _ok(path.name) + + _MOD.collect(files, root, fake_compile, jobs=4) + assert len(threads) > 1, "every compile ran on the calling thread" + + def test_both_branches_agree(self) -> None: + root = Path("/repo") + files = [root / "examples" / f"p{index}.vera" for index in range(8)] + + def fake_compile(path: Path) -> Any: + return _ok(f"digest-of-{path.name}") + + assert _MOD.collect(files, root, fake_compile, jobs=1) == _MOD.collect( + files, root, fake_compile, jobs=4 + ) + + +class TestSideEnvironment: + """`_side_env`, which had no test at all (#1329 review).""" + + def test_pythonpath_is_replaced_not_extended( + self, monkeypatch: Any + ) -> None: + """The caller's `PYTHONPATH` usually names the head checkout — + that is how this repo is driven. Inheriting it on the base side + puts the head compiler first on the path, so the differential + compares a revision against itself and reports zero movers: the + vacuity `canary_error` exists to catch, arriving one layer down. + """ + monkeypatch.setenv("PYTHONPATH", "/repo") + env = _MOD._side_env(Path("/scratch/base")) + assert env["PYTHONPATH"] == str(Path("/scratch/base")) + assert "/repo" not in env["PYTHONPATH"] + + def test_bytecode_writing_is_off_for_both_checkouts( + self, monkeypatch: Any + ) -> None: + """Scrubbed from the ambient environment first, deliberately. + + This suite is itself run with `PYTHONDONTWRITEBYTECODE=1`, and + `_side_env` copies `os.environ` — so without the scrub the + assertion is satisfied by the caller's shell and passes with the + line under test deleted. It measures the function only when the + variable is absent to begin with. + """ + monkeypatch.delenv("PYTHONDONTWRITEBYTECODE", raising=False) + env = _MOD._side_env(Path("/scratch/base")) + assert env["PYTHONDONTWRITEBYTECODE"] == "1" + + def test_the_rest_of_the_environment_is_inherited( + self, monkeypatch: Any + ) -> None: + """Only those two keys are the function's business: the base + compiler still needs the venv's interpreter and its PATH.""" + monkeypatch.setenv("VERA_SIDE_ENV_PROBE", "kept") + assert _MOD._side_env(Path("/scratch/base"))["VERA_SIDE_ENV_PROBE"] == "kept" + + +class TestTimeoutValidation: + """`--timeout` must be able to elapse (#1329 review). + + Zero or negative expires before any compile finishes, so both sides + fail every program, `compare` counts them all as `both_failed`, and + `emit` reports "No movers" with exit 0 over a corpus that never + compiled — a green run measuring nothing. + """ + + @pytest.mark.parametrize("value", ["0", "-1"]) + def test_a_non_positive_budget_is_rejected(self, value: str) -> None: + with pytest.raises(argparse.ArgumentTypeError, match="greater than zero"): + _MOD._positive_seconds(value) + + def test_a_positive_budget_is_accepted(self) -> None: + assert _MOD._positive_seconds("120") == 120 + + @pytest.mark.parametrize("value", ["0", "-1"]) + def test_the_parser_refuses_it_too(self, value: str) -> None: + """Wired into `--timeout`, not merely defined beside it.""" + with pytest.raises(SystemExit): + _MOD._parse_args(["--timeout", value]) + + +class TestUndecodableCompilerOutput: + """A compiler byte the codec cannot read must stay data (#1329 review). + + Strict decoding raises `UnicodeDecodeError` out of `subprocess.run` + itself — a `ValueError`, which neither handler in `compile_one` + catches — and `collect` iterates `ThreadPoolExecutor.map`, so that + one program would abort the whole corpus run. + """ + + def test_the_compile_asks_for_lenient_decoding( + self, monkeypatch: Any + ) -> None: + seen: dict[str, Any] = {} + + def fake_run(*args: Any, **kwargs: Any) -> Any: + seen.update(kwargs) + raise subprocess.TimeoutExpired(cmd="x", timeout=1) + + monkeypatch.setattr(_MOD.subprocess, "run", fake_run) + _MOD.compile_one("python", Path("/scratch"), 5, Path("/repo/a.vera")) + assert seen.get("encoding") == "utf-8" + assert seen.get("errors") == "replace" + + def test_strict_decoding_is_what_would_have_raised(self) -> None: + """The reason the kwarg above matters, measured rather than + asserted: the same bytes through the same call raise on strict + and survive on replace.""" + program = "import sys; sys.stdout.buffer.write(b'\\x97')" + command = [sys.executable, "-c", program] + with pytest.raises(UnicodeDecodeError): + subprocess.run( + command, + capture_output=True, + text=True, + encoding="utf-8", + check=False, + ) + lenient = subprocess.run( + command, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) + assert lenient.returncode == 0 diff --git a/tests/test_check_doc_counts.py b/tests/test_check_doc_counts.py index 8251e924..2a3c016a 100644 --- a/tests/test_check_doc_counts.py +++ b/tests/test_check_doc_counts.py @@ -26,6 +26,7 @@ from __future__ import annotations import importlib.util +import re from pathlib import Path from typing import Any @@ -844,6 +845,24 @@ def test_classified_skips_that_miss_the_summary_total_are_an_error(self) -> None def test_a_report_with_no_summary_line_is_an_error_not_a_skip(self) -> None: assert _MOD.parse_dual_target_report("nothing to see here\n") is None + def test_a_summary_omitting_the_skipped_category_is_read(self) -> None: + """pytest prints no category with a zero count, so `174 passed in + 3.1s` is a well-formed summary. Requiring both groups made it + unreadable, and an unreadable report is reported as drift — a + false failure (#1329 review).""" + split = _MOD.parse_dual_target_report("174 passed in 3.15s\n") + assert split == _MOD.DualTargetSplit(174, 0, 0, 0, 0) + + def test_a_summary_omitting_the_passed_category_is_read(self) -> None: + """The complement: every run-level programme skipped.""" + report = ( + "SKIPPED [52] host family: map\n" + "52 skipped in 3.15s\n" + ) + assert _MOD.parse_dual_target_report(report) == _MOD.DualTargetSplit( + 0, 52, 52, 0, 0 + ) + # --------------------------------------------------------------------------- # KNOWN_ISSUES' Bugs table (#1290 rider): one row per open `bug` issue. @@ -871,7 +890,20 @@ def test_the_shipped_table_parses(self) -> None: ) rows = _MOD.bug_rows(text) assert rows is not None - assert len(rows) > 5, "the Bugs table is no longer being read" + # The floor is derived from the file, not a literal: `> 5` would + # fail the day the tracker is burned down to five open bugs, which + # is a project state rather than a regression — and it would not + # catch the parser returning a SHORT list, which is the failure + # worth naming (#1329 review). + section = re.search(r"^## Bugs[ \t]*$(.*?)(?=^## )", text, re.M | re.S) + assert section is not None + table = [ + line + for line in section.group(1).splitlines() + if line.startswith("|") and not set(line) <= set("|- ") + ][1:] # drop the header row + assert table, "the Bugs table is no longer being read" + assert len(rows) == len(table) assert len(set(rows)) == len(rows) def test_a_row_with_no_issue_link_is_an_error(self) -> None: @@ -938,6 +970,17 @@ def test_the_parity_check_is_not_wired_into_the_default_run(self) -> None: """A pre-commit hook must not depend on the GitHub API.""" source = _SCRIPT.read_text(encoding="utf-8") assert "--check-bug-issues" in source - index = source.rindex("check_bug_issue_parity(") - guarded = source[max(0, index - 600) : index] - assert "args.check_bug_issues" in guarded + # EVERY call site, not just the last one: `rindex` inspected only + # the final occurrence, so an unguarded call added above it would + # leave this green while the pre-commit hook made a network call + # (#1329 review). + calls = [ + index + for index in range(len(source)) + if source.startswith("check_bug_issue_parity(", index) + and not source.startswith("def check_bug_issue_parity(", max(0, index - 4)) + ] + assert calls, "the parity check is no longer called at all" + for index in calls: + guarded = source[max(0, index - 600) : index] + assert "args.check_bug_issues" in guarded diff --git a/tests/test_check_examples_run.py b/tests/test_check_examples_run.py index 243fbce7..5980deb7 100644 --- a/tests/test_check_examples_run.py +++ b/tests/test_check_examples_run.py @@ -138,6 +138,20 @@ def _load() -> Any: _UNPARSEABLE_SRC = "public fn main(-> @Int) { this is not Vera at all\n" +# A module-qualified effect whose tail is `DB`. `Mod.DB` names a user +# effect in another module, not the built-in the registry check validated, +# so the derivation must not credit it (#1329 review). +_QUALIFIED_DB_SRC = """\ +public fn main(-> @Int) + requires(true) + ensures(true) + effects() +{ + 0 +} +""" + + def _corpus(tmp_path: Path, programs: dict[str, str]) -> Path: d = tmp_path / "examples" d.mkdir(exist_ok=True) @@ -379,6 +393,39 @@ def test_signals_come_from_the_declarations_not_the_comments( d = _corpus(tmp_path, {"prose": _COMMENTED_SRC}) assert _MOD.resource_signals(d / "prose.vera") == frozenset() + def test_a_pinned_spec_whose_file_is_gone_is_not_mis_diagnosed( + self, tmp_path: Path + ) -> None: + """The derivation never opened it, so it cannot say what it + declares. Building `pinned` from every entry in `run_specs` put + this spec into `pinned - signals_by_name`, where it drew the + spurious-sentinel wording — "declares no external resource" — + for a file that does not exist (#1329 review). + """ + d = _corpus(tmp_path, {"present": _DB_SRC}) + specs = { + "present": _MOD.RunSpec(expect="created the table"), + "vanished": _MOD.RunSpec(expect="never printed"), + } + errors = _MOD.check_sentinel_coverage(d, specs) + assert not [e for e in errors if "vanished" in e] + + def test_a_module_qualified_effect_is_not_the_builtin( + self, tmp_path: Path + ) -> None: + """`Mod.DB` is another module's effect, not the registry's `DB`. + + `resource_signals` narrows on `isinstance(ref, ast.EffectRef)` + precisely to drop it, and no cell reached that decision: a mutant + dropping the narrowing, or one crediting any reference whose tail + is `DB`, passed every other case here. This is the boundary in + the direction `_COMMENTED_SRC` does not cover — that one is prose, + this one is a real declaration of a different effect (#1329 + review). + """ + d = _corpus(tmp_path, {"qualified": _QUALIFIED_DB_SRC}) + assert _MOD.resource_signals(d / "qualified.vera") == frozenset() + def test_a_renamed_resource_effect_is_an_error( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -426,6 +473,13 @@ def test_a_resource_op_under_an_unknown_effect_is_an_error( assert len(errors) == 1 assert "FileIO" in errors[0] assert "could not find" in errors[0] + # The discriminating half. BOTH branches say "could not find" and + # both interpolate `{effect}.{op}`, so the three assertions above + # are satisfied by either — including by the operation-missing + # branch, which would have raised KeyError on `live_ops[effect]` + # to get there. This pins the branch that avoids it (#1329 review). + assert "has no 'FileIO'" in errors[0] + assert "read_file" not in errors[0].split("could not find")[1] class TestDerivedSentinelCoverage: diff --git a/tests/test_grammar_alignment.py b/tests/test_grammar_alignment.py index 757ab661..41095fe7 100644 --- a/tests/test_grammar_alignment.py +++ b/tests/test_grammar_alignment.py @@ -370,6 +370,31 @@ def test_a_note_between_declarations_does_not_end_the_skipped_group(self) -> Non assert "BLOCK_COMMENT" in _MOD.skipped_terminals(_spec_lines()) assert "ANNOTATION_COMMENT" in _MOD.skipped_terminals(_spec_lines()) + def test_a_blank_line_closes_the_skipped_group(self) -> None: + """A block with no heading of its own inherits nothing. + + `in_group` changed only when a comment opened a block, so a + declaration block following a blank line kept whatever the + previous block was — silently waiving terminals the marker never + named (#1329 review). + """ + # Injected directly AFTER the skipped group, which is the only + # placement that distinguishes: appended at the end of the fence + # the block would follow a group that is not the skipped one, so + # it inherits `False` and the cell passes either way. + spec: list[str] = [] + for line in _spec_lines(): + spec.append(line) + if line.startswith("ANNOTATION_COMMENT:"): + spec += ["", 'UNHEADED_TERMINAL: "unheaded"'] + assert 'UNHEADED_TERMINAL: "unheaded"' in spec, "injection point gone" + assert "ANNOTATION_COMMENT" in _MOD.skipped_terminals(spec), ( + "the skipped group itself must still be recognised" + ) + assert "UNHEADED_TERMINAL" not in _MOD.skipped_terminals(spec) + problems = _MOD.terminal_audit(_lark_lines(), spec) + assert [p for p in problems if "UNHEADED_TERMINAL" in p] + def test_the_skipped_group_does_not_swallow_the_whole_fence(self) -> None: skipped = _MOD.skipped_terminals(_spec_lines()) assert "FN" not in skipped and "INT_LIT" not in skipped @@ -496,10 +521,26 @@ def test_a_terminal_only_lark_names_is_caught(self) -> None: ] def test_a_rule_referring_to_itself_is_not_drift(self) -> None: - """Lark spells repetition with left recursion, the chapter with `*`.""" - lark = _MOD.rule_bodies(_lark_lines()) - assert "add_expr" in "".join(lark["add_expr"]), "no longer left-recursive" - assert not [p for p in _MOD.body_drift(_lark_lines(), _spec_lines())] + """Lark spells repetition with left recursion, the chapter with `*`. + + Asserted at the symbol level. Re-asserting that `body_drift` + reports nothing only repeats the clean-file cell above and would + stay green if the exclusion were dropped and the chapter grew a + matching self-reference (#1329 review). + """ + lark_bodies = _MOD.rule_bodies(_lark_lines()) + spec_bodies = _MOD.rule_bodies(_spec_lines()) + assert "add_expr" in "".join(lark_bodies["add_expr"]), "not left-recursive" + + rules, _terminals, _inlined = _MOD._spec_symbols( + "add_expr", spec_bodies, set(spec_bodies) + ) + assert "add_expr" not in rules + lark_rules, _t, _u = _MOD._lark_symbols( + "add_expr", lark_bodies, set(lark_bodies), {} + ) + assert "add_expr" not in lark_rules + assert lark_rules, "the extraction returned nothing at all" def test_a_waived_production_is_folded_at_the_rule_the_waiver_names(self) -> None: """`fn_call` inlines what the chapter factors into `module_call`.""" @@ -511,14 +552,38 @@ def test_a_waived_production_is_folded_at_the_rule_the_waiver_names(self) -> Non assert "module_call" not in rules and "qualified_call" not in rules def test_an_aliased_alternative_is_not_read_as_a_rule_reference(self) -> None: + """`func_call` is a real alias — `vera/grammar.lark` spells the + first `fn_call` alternative `-> func_call` — so this assertion is + falsifiable, and the mutation that stops `rule_bodies` stripping + aliases kills it. The positive control below is what stops an + empty body from satisfying it. + """ bodies = _MOD.rule_bodies(_lark_lines()) - assert "func_call" not in "".join(bodies["fn_call"]) + aliases = {alias for rule, alias in _MOD.extract_lark_aliases(_lark_text()) + if rule == "fn_call"} + assert "func_call" in aliases, "the alias this cell rests on is gone" + + body = "".join(bodies["fn_call"]) + assert "LOWER_IDENT" in body, "positive control: the body was read" + for alias in aliases: + assert alias not in body class TestCommentStripping: - def test_a_regex_body_ending_in_a_slash_is_not_truncated(self) -> None: + @pytest.mark.parametrize( + "line", + [ + # Lark's spelling, which escapes the class slash. + r"%ignore /\/\*[^*]*\*+([^\/*][^*]*\*+)*\//", + # The chapter's spelling, which does not — the case that was + # truncated inside the character class (#1329 review). + r"ANNOTATION_COMMENT: /\/\*[^*]*\*+([^/*][^*]*\*+)*\//", + ], + ) + def test_a_regex_body_ending_in_a_slash_is_not_truncated( + self, line: str + ) -> None: """`line.split("//")[0]` cut the annotation-comment terminal in half.""" - line = r"%ignore /\/\*[^*]*\*+([^\/*][^*]*\*+)*\//" assert _MOD.strip_comment(line) == line def test_a_comment_after_a_regex_is_still_removed(self) -> None: @@ -531,3 +596,77 @@ def test_a_double_slash_inside_a_literal_is_not_a_comment(self) -> None: def test_a_whole_line_comment_is_still_removed(self) -> None: assert _MOD.strip_comment("// assert_stmt: gone").strip() == "" + + +class TestCharacterClasses: + """A `/` inside a regex character class is not the delimiter (#1329). + + `strip_comment` scanned a `/…/` body for the next unescaped `/`, and + the chapter spells the annotation-comment terminal `[^/*]` where the + Lark grammar spells it `[^\\/*]`. The scan therefore ended inside + the class, truncating the declaration — and a truncated body is not + a bare regex, so `terminal_patterns` skipped the terminal entirely. + The gate was green on that terminal by never looking at it. + """ + + def test_the_specs_annotation_comment_line_survives_the_scan(self) -> None: + line = next( + raw + for raw in _spec_lines() + if raw.startswith("ANNOTATION_COMMENT:") + ) + assert "[^/*]" in line, "the chapter no longer spells the class bare" + assert _MOD.strip_comment(line) == line + + def test_the_annotation_comment_pattern_is_actually_compared(self) -> None: + """Non-vacuity: the terminal must reach the pattern check at all. + + A truncated body fails `_BARE_REGEX`, and a terminal that is not + a bare regex is skipped by design — so this is the assertion that + separates "compared and equal" from "never compared". + """ + body = _MOD.terminal_declarations(_spec_lines())["ANNOTATION_COMMENT"] + assert _MOD._BARE_REGEX.match(body), f"not a bare regex: {body!r}" + + def test_the_two_files_spell_the_class_differently_and_still_agree(self) -> None: + spec = _MOD.terminal_declarations(_spec_lines())["ANNOTATION_COMMENT"] + lark = next( + body for body in _MOD.ignore_patterns(_lark_lines()) if "\\*" in body + ) + assert spec != lark, "the normalisation would be doing no work" + assert _MOD.normalise_pattern(spec) == _MOD.normalise_pattern(lark) + + def test_a_drifted_annotation_comment_is_now_caught(self) -> None: + """The gate must fail on this terminal, not skip it. + + Before the character-class fix this mutation left the gate green: + the body was truncated, so no pattern was compared at all. + """ + # The drift keeps the bare `[^/*]` class the chapter really uses, + # so this cell exercises the truncation rather than sidestepping + # it: with an escaped class it would be caught either way. + spec = [ + r"ANNOTATION_COMMENT: /\/\*[^/*]XX[^*]*\*+\//" + if line.startswith("ANNOTATION_COMMENT:") + else line + for line in _spec_lines() + ] + problems = _MOD.terminal_patterns(_lark_lines(), spec) + assert [p for p in problems if "ANNOTATION_COMMENT" in p] + + @pytest.mark.parametrize( + "line", + [ + r"T: /[^/*]/", + r"T: /[/]/", + r"T: /[abc/def]x/", + r"T: /[^]/]/", + ], + ) + def test_a_slash_inside_a_character_class_is_not_the_delimiter( + self, line: str + ) -> None: + assert _MOD.strip_comment(line) == line + + def test_a_comment_after_a_class_bearing_regex_is_still_removed(self) -> None: + assert _MOD.strip_comment(r"T: /[^/*]/ // note") == r"T: /[^/*]/ " diff --git a/vera/README.md b/vera/README.md index aa189f15..9f61c08c 100644 --- a/vera/README.md +++ b/vera/README.md @@ -753,7 +753,7 @@ The `ERROR_CODES` dict in `errors.py` maps every code to a short description (16 ## Test Suite -Testing spans a **pytest suite** of 11,907 tests across 175 files — compiler-internals unit tests plus a **conformance suite** (244 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. +Testing spans a **pytest suite** of 11,938 tests across 175 files — compiler-internals unit tests plus a **conformance suite** (244 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. See **[TESTING.md](../TESTING.md)** for the comprehensive testing reference -- test file table, conformance suite details, compiler code coverage, language feature coverage, helper conventions, validation scripts, CI pipeline, and guidelines for adding tests. From c26a7ee69770ef7729503ed1c1f847b854480124 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Sat, 15 Aug 2026 01:59:45 +0100 Subject: [PATCH 59/69] Assert where the pooled work ran, not how many threads ran it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wall was red on all seven real cells with one failing test, `TestParallelCollection::test_the_parallel_branch_is_the_one_being _exercised`, and the cause is not the one the failure message named. `collect` selects its branch on `if jobs <= 1`, reading the caller's explicit argument; the cell passes `jobs=4`, so the parallel branch was taken on CI exactly as it is here. The only cpu-derived value in the script is the `--jobs` CLI default, which the cell never reaches. What varied was `ThreadPoolExecutor`'s own scheduling: it creates a worker only when no idle one is available, so a few trivial callables can be drained by a single worker before `map` has finished submitting them. Measured over 200 trials on this 12-core host the distinct-thread count came out 2, 3 or 4 — never a fixed number — and with two items instead of eight it is 1 here too, which reproduces the CI failure locally on a machine with six times the cores. `len(threads) > 1` was asserting a scheduling lottery, and the cell inherited whichever way the host happened to resolve it. The assertion is now the property its own failure message already claimed: the pool never runs work inline, so the calling thread must not appear among the threads that ran a compile. That holds for every worker count — over a 20-cell sweep of jobs x item counts the calling-thread predicate was true exactly when `jobs <= 1`, including the four cells where a pool of 2, 3, 4 or 8 workers spawned exactly one thread. A complementary cell asserts the other direction, so the two branches are told apart by one observation rather than by a count. No new seam was needed: `collect` already takes `jobs`, and the CLI already exposes `--jobs`. Both directions are mutation-checked — the pool-drop mutant this cell exists for still dies, and so does a mutant that gives the sequential branch a pool. Co-Authored-By: Claude --- FAQ.md | 2 +- README.md | 2 +- ROADMAP.md | 2 +- TESTING.md | 4 +-- docs/llms-full.txt | 2 +- tests/test_check_corpus_differential.py | 36 +++++++++++++++++++++++-- vera/README.md | 2 +- 7 files changed, 41 insertions(+), 9 deletions(-) diff --git a/FAQ.md b/FAQ.md index 5f7e0065..3e508bbc 100644 --- a/FAQ.md +++ b/FAQ.md @@ -279,7 +279,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 11,938 tests, including a 244-program conformance suite +- 11,939 tests, including a 244-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/README.md b/README.md index 01985c6d..5ada0e0a 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ cp /path/to/vera/SKILL.md ~/.claude/skills/vera-language/SKILL.md ## Project status -Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 11,938 tests, 95% Python code coverage, 244 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. +Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 11,939 tests, 95% Python code coverage, 244 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. The reference compiler — parser, AST, type checker, contract verifier (Z3), WASM code generator, module system, browser runtime, and runtime contract insertion — is working. The language specification is in draft across [14 chapters](spec/). diff --git a/ROADMAP.md b/ROADMAP.md index ecd01ead..861b6939 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,7 +12,7 @@ Ordering derives from the design principles ([DESIGN.md](DESIGN.md)): verificati ## Where we are -11,938 tests, 244 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. +11,939 tests, 244 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. ## Stage 19 — The verification completeness sprint diff --git a/TESTING.md b/TESTING.md index 07d34c70..6f95e2c7 100644 --- a/TESTING.md +++ b/TESTING.md @@ -6,7 +6,7 @@ This is the single source of truth for Vera's testing infrastructure, coverage d | Metric | Value | |--------|-------| -| **Tests** | 11,938 across 175 files (~155,000 lines of test code; 11,739 passed + 26 stress, 173 skipped) | +| **Tests** | 11,939 across 175 files (~155,000 lines of test code; 11,740 passed + 26 stress, 173 skipped) | | **Compiler code coverage** | 95% Python, 87% JavaScript (CI minimum: 80%) | | **Conformance programs** | 244 programs across 9 spec chapters, validating every language feature | | **Example programs** | 42, all validated through `vera check` + `vera verify` | @@ -225,7 +225,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_check_doc_counts.py` | 89 | 986 | `check_doc_counts.py`'s pure per-document checks: KNOWN_ISSUES refactoring line counts (±10% tolerance band incl. the exact-boundary case, drift detection, empty-file citation, hyphenated paths, missing file/section/rows, the #419 empty-section sentinel + its cannot-mask-a-malformed-table dual), HISTORY version-row format (issue-link limit, ` — ` separator rejection, dateless-row and prose exemption, line-number reporting), the TESTING.md tests breakdown (parts summing to the collected total, a self-consistent-but-stale row, and the reworded-row error), and vera/README.md's Test Suite counts (all four checked independently — mutation-validated by dropping each citation in turn — plus the reworded-paragraph error, a thousands-separator case pinning that every one of the four counts is read comma-tolerantly, and the two section-anchoring cases — a reworded paragraph with decoy counts in a later section, and a renamed heading, both of which must fail loud rather than match across the section boundary). The reworded case is a test in its own right for both new checks: a pattern that matches nothing must be an error, or rewording the sentence silently switches the gate off. Also the release count (README's status line and HISTORY's total against each other and against `git tag`: the matching case, the one-ahead release cut that `release.yml` has not tagged yet, that +1 being the ONLY slack once the version is tagged, the two-behind drift that actually shipped, per-document reporting, and a tagless checkout standing the oracle down without standing down the cross-check), plus the tag reader itself against real repositories built in `tmp_path` — release tags read, `nightly`/`-rc1` not counted as releases, and both no-evidence answers (`None` rather than `[]`, since an empty list would read as zero releases and make every documented count wrong) for a tagless checkout and a directory that is not a repository at all. CONTRIBUTING.md's pre-commit hook count is checked the same way, reworded-sentence case included; and the CI-pipeline lint row against `ci.yml`'s lint job — a matching row, a step present in CI but absent from the row (the drift that shipped), a row entry CI no longer runs, the same set in a different order, a reworded row and a renamed job (both errors, not skips), that only the lint job is read rather than the whole workflow, and the shipped pair both clean and red with one entry dropped | | `test_grammar_alignment.py` | 85 | 672 | `check_grammar_alignment.py` gate ([#683](https://github.com/aallan/vera/issues/683)): both extractors (Lark headers with the `?`/`!`/`_` markers stripped, template parameters and rule priorities tolerated, and `-> alias` names deliberately not collected; spec headers from ```ebnf fences only), the allowlist arithmetic in all three directions — unwaived drift, a spent entry both files now have, and an entry whose premise broke — one case per waiver proving the fact it rests on is actually checked, the name-deleted-from-both-files case that must not read as agreement, a mutation restoring the spec's old `assert_stmt` name, non-vacuous extraction, and the false positive the issue itself rested on: `qualified_call` and `module_call` are spec headers Lark expresses as aliases, and must never be reported as drift. Three pin the premise checks against ways they used to pass vacuously: an alias surviving only inside a `//` comment must not hold its waiver up, an alias that moved to another rule must fail the waiver naming `fn_call`, and a spent waiver whose premise also broke must yield one instruction rather than two opposite ones | | `test_check_examples_run.py` | 77 | 1,280 | `check_examples_run.py`, the harness gate that RUNS the examples. Five separable parts, each in both directions. **The coverage rule** — the shipped tables cover the shipped corpus exactly, and an unclassified example, a stale `RUN_SPECS` or `SKIPS` key whose file is gone, a name in both tables, and a skip citing an undocumented property are each an error; the empty corpus is an error too, since a glob that stops matching would otherwise report success over zero programs. Plus the specs' own well-formedness: every named entry point is `public` in its example, every no-main example pins one (or `vera run` would fall back to an arbitrary first export), and no skip property is unused. **The runner** — a seeded `tmp_path` corpus proves it goes red on a program that type-checks and compiles but traps at run time, green on one that does not, and reports only the broken member of a mixed pair; a fixture whose first export is clean and whose named one traps proves `spec.fn` is actually honoured rather than ignored; a writer program proves each run gets a scratch working directory, so a gate run leaves nothing beside the examples. **The TESTING.md cross-check** — missing row, extra row, rename (reported naming both sides), wrong disposition and wrong skip property are errors, the parse stops at the next heading so a row-shaped line in a later section is not swept in, and both a reworded heading and a heading whose table has vanished fail loud rather than finding nothing to compare. **The output signal** -- the second half of the two-signal discipline `check_examples.py` established: the fallback note that `vera run` prints when it cannot use the named entry point is a failure even at exit 0, an absent `expect` sentinel is a failure even at exit 0, and a spec without one asserts nothing about output; end to end, a privatised `main` and a program that completed down a graceful arm each go red, and the same program passes once its own output is the sentinel, so the check reads the output rather than always failing. Which specs must carry a sentinel is **derived** from what each example declares — a resource effect in a function's effect row, or a call to a resource operation, both validated against the live effect registry so a renamed effect or op fails loudly — and asserted equal to the specs that have one, in both directions; the previous hard-coded triple could not see a fourth such example arriving without one. The runner's use of BOTH streams is pinned structurally -- `vera run` writes the note to stderr and nothing there on a clean exit, so no fixture can distinguish reading both streams from reading stdout alone, and a tripwire wired to the wrong stream is no tripwire. Also the hermetic-environment property: an ambient `VERA_DB_URL` or provider key is stripped so a gate run cannot be pointed at a real database or turned into a billed API call, a fixture spec puts its own URL back, and every neutralised name is checked to be one `vera/runtime/` actually reads | -| `test_check_corpus_differential.py` | 44 | 710 | `check_corpus_differential.py`, the burndown instrument that compiles the corpus at two revisions. The pure pieces only — the real two-revision run costs minutes and is not a test. **Classification**, all four verdicts: identical, WAT differs, and each one-sided compile failure as its own kind, since a compilability reversal reported as a text difference is the mis-description the instrument exists to avoid; failing at both revisions is not a mover and is counted separately, so a green run states how much of it was vacuous. **Enumeration** — recursive, keyed by repo-relative POSIX path, and an empty corpus is an error rather than a clean run over nothing. **The canary** — each side must import the compiler it was pointed at, so a side silently resolving to the venv's editable install cannot compare a revision against itself; an import failure and a foreign compiler are different messages. **Reporting** — every mover named with its reason, the exit code, the `--json` shape, and a program missing from one side reported rather than dropped. One test asserts the instrument is absent from `.pre-commit-config.yaml`, so the docstring's claim cannot rot | +| `test_check_corpus_differential.py` | 45 | 742 | `check_corpus_differential.py`, the burndown instrument that compiles the corpus at two revisions. The pure pieces only — the real two-revision run costs minutes and is not a test. **Classification**, all four verdicts: identical, WAT differs, and each one-sided compile failure as its own kind, since a compilability reversal reported as a text difference is the mis-description the instrument exists to avoid; failing at both revisions is not a mover and is counted separately, so a green run states how much of it was vacuous. **Enumeration** — recursive, keyed by repo-relative POSIX path, and an empty corpus is an error rather than a clean run over nothing. **The canary** — each side must import the compiler it was pointed at, so a side silently resolving to the venv's editable install cannot compare a revision against itself; an import failure and a foreign compiler are different messages. **Reporting** — every mover named with its reason, the exit code, the `--json` shape, and a program missing from one side reported rather than dropped. One test asserts the instrument is absent from `.pre-commit-config.yaml`, so the docstring's claim cannot rot | | `test_check_editor_grammars.py` | 20 | 249 | `check_editor_grammars.py` gate ([#1156](https://github.com/aallan/vera/issues/1156)): the registry read (every effect in, every ability out, and the four names the grammars actually drifted on present so the set checked is non-vacuous), the word-boundary presence test across all three grammar formats (JSON, plist XML, Vim keyword list) including the prefix pair `Http`/`HttpServer` in both directions, the deliberate comment-mention false pass, a metacharacter pair that only passes when the name is matched literally (`A.B` present, `A0B` not), and the empty registry; the gate's primary path end to end — a listed grammar with an effect stripped out, and a listed README with one stripped out of its prose bullet, each red against an otherwise-clean mirrored tree; the completeness guard — a grammar discovered under `editors/` but absent from `GRAMMARS` fails that same tree, over three discovery routes (`.el` outside a syntax directory, a tree-sitter `.scm` query set, a `.tmLanguage.json` filed anywhere but `syntaxes/`); and the registry's provenance, run as a subprocess against a throwaway checkout whose `vera` package names an effect the grammars do not, which is the only way to see that the list comes from the tree being checked rather than from site-packages. The shipped grammars and READMEs are currently clean | | `test_check_explicit_encoding.py` | 54 | 254 | `check_explicit_encoding.py` gate (#645): flags text-mode `open()` / `read_text()` / `write_text()` **and** `subprocess.run/Popen/check_output(..., text=True)` captures missing an `encoding="utf-8"` literal (rejects non-literal / non-UTF-8 values), skips binary/bytes-mode calls, honours the `# encoding-exempt` opt-out, and asserts the shipped repo is clean | | `test_check_limitations_sync.py` | 6 | 108 | `check_limitations_sync.py` section extraction: table-rows-only issue harvesting, prose-link exemption, bounding at the next second-level heading, `None` for absent or sub-level headings so renamed sections fail loudly; plus the #852 fail-loud rule: an UNKNOWN issue state under `--check-states` (gh missing / auth failure / timeout) is an error, never a silent pass | diff --git a/docs/llms-full.txt b/docs/llms-full.txt index f8d26c31..c5bad4f6 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -3265,7 +3265,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 11,938 tests, including a 244-program conformance suite +- 11,939 tests, including a 244-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/tests/test_check_corpus_differential.py b/tests/test_check_corpus_differential.py index 225cf1f1..773b1566 100644 --- a/tests/test_check_corpus_differential.py +++ b/tests/test_check_corpus_differential.py @@ -573,7 +573,20 @@ def fake_compile(path: Path) -> Any: def test_the_parallel_branch_is_the_one_being_exercised(self) -> None: """Non-vacuity: `jobs=4` must not quietly fall through to the - sequential path, or this class tests nothing new.""" + sequential path, or this class tests nothing new. + + The property is *where* the work ran, not how many threads the + pool chose to spawn. `ThreadPoolExecutor` creates a worker only + when no idle one is available, so a handful of trivial callables + can be drained by a single worker before `map` finishes + submitting them: measured over 200 trials on a 12-core host the + distinct-thread count came out 2, 3 or 4, and on a 2-core CI + runner it is 1. Asserting `len(threads) > 1` therefore inherited + the host's scheduling — green here, red on every CI cell. What + *is* invariant is that the pool never executes inline: the + calling thread ran work in 0 of those 200 trials, and 0 of any, + because `submit` always hands the callable to a worker. + """ root = Path("/repo") files = [root / "examples" / f"p{index}.vera" for index in range(8)] threads: set[int] = set() @@ -583,7 +596,26 @@ def fake_compile(path: Path) -> Any: return _ok(path.name) _MOD.collect(files, root, fake_compile, jobs=4) - assert len(threads) > 1, "every compile ran on the calling thread" + assert threads, "no compile ran at all" + assert threading.get_ident() not in threads, ( + "a compile ran on the calling thread, so `jobs=4` fell through " + "to the sequential branch" + ) + + def test_the_sequential_branch_runs_inline(self) -> None: + """The complement, and the reason the cell above is not vacuous: + `jobs=1` must run on the caller, so the two branches are told + apart by the same observation rather than by a count.""" + root = Path("/repo") + files = [root / "examples" / f"p{index}.vera" for index in range(8)] + threads: set[int] = set() + + def fake_compile(path: Path) -> Any: + threads.add(threading.get_ident()) + return _ok(path.name) + + _MOD.collect(files, root, fake_compile, jobs=1) + assert threads == {threading.get_ident()} def test_both_branches_agree(self) -> None: root = Path("/repo") diff --git a/vera/README.md b/vera/README.md index 9f61c08c..60f722aa 100644 --- a/vera/README.md +++ b/vera/README.md @@ -753,7 +753,7 @@ The `ERROR_CODES` dict in `errors.py` maps every code to a short description (16 ## Test Suite -Testing spans a **pytest suite** of 11,938 tests across 175 files — compiler-internals unit tests plus a **conformance suite** (244 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. +Testing spans a **pytest suite** of 11,939 tests across 175 files — compiler-internals unit tests plus a **conformance suite** (244 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. See **[TESTING.md](../TESTING.md)** for the comprehensive testing reference -- test file table, conformance suite details, compiler code coverage, language feature coverage, helper conventions, validation scripts, CI pipeline, and guidelines for adding tests. From e6e7cb653b93e827ceb8459b2dffce2cd6b02b46 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Sat, 15 Aug 2026 02:29:01 +0100 Subject: [PATCH 60/69] Prove the decode with the decoder, not with a child process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three Windows cells failed on `TestUndecodableCompilerOutput::test_strict_decoding_is_what_would_have _raised` with "DID NOT RAISE", and the decode was not what varied. Both of that cell's calls already named `encoding="utf-8"`, as do all five `subprocess.run` calls in the script it guards; `check_explicit_encoding` agrees. So the codec was pinned on every platform and the byte reaching it was not: what a child process puts on a pipe is the operating system's business, and the cell was measuring that round-trip in order to make a claim about decoding. It now measures the decoding. `io.TextIOWrapper` is not a stand-in for what `subprocess.run` does — it is what `subprocess.run` does, since `Popen` wraps each captured pipe in exactly that object with exactly the `encoding` and `errors` it was handed. Reading `b"\x97"` through one raises on strict and yields the replacement character on `replace`, with no child, no pipe and nothing platform-dependent left in the cell. A second cell pins the fixture: the byte has to be undecodable in the codec the script actually names. `b"\x97"` is invalid UTF-8 but decodes under cp1252 as an em dash, so a decode left to the platform default would not have raised on Windows either — the byte is only a good fixture while the codec is spelled out, which is the same reason the script spells it out. Swapping the fixture for a UTF-8-safe byte kills the pair. The guard on `compile_one` is unchanged and still held by `test_the_compile_asks_for_lenient_decoding`, which pins the call's kwargs: removing either `errors="replace"` or `encoding="utf-8"` from the script goes red there. These two cells are the demonstration of why that guard matters, and making the twin itself exercise the call path would mean spawning the child again, which is the shape being removed. Co-Authored-By: Claude --- FAQ.md | 2 +- README.md | 2 +- ROADMAP.md | 2 +- TESTING.md | 4 +- docs/llms-full.txt | 2 +- tests/test_check_corpus_differential.py | 58 ++++++++++++++++--------- vera/README.md | 2 +- 7 files changed, 44 insertions(+), 28 deletions(-) diff --git a/FAQ.md b/FAQ.md index 3e508bbc..5e920fbc 100644 --- a/FAQ.md +++ b/FAQ.md @@ -279,7 +279,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 11,939 tests, including a 244-program conformance suite +- 11,940 tests, including a 244-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/README.md b/README.md index 5ada0e0a..fe40b36b 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ cp /path/to/vera/SKILL.md ~/.claude/skills/vera-language/SKILL.md ## Project status -Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 11,939 tests, 95% Python code coverage, 244 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. +Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 11,940 tests, 95% Python code coverage, 244 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. The reference compiler — parser, AST, type checker, contract verifier (Z3), WASM code generator, module system, browser runtime, and runtime contract insertion — is working. The language specification is in draft across [14 chapters](spec/). diff --git a/ROADMAP.md b/ROADMAP.md index 861b6939..d3e744e5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,7 +12,7 @@ Ordering derives from the design principles ([DESIGN.md](DESIGN.md)): verificati ## Where we are -11,939 tests, 244 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. +11,940 tests, 244 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. ## Stage 19 — The verification completeness sprint diff --git a/TESTING.md b/TESTING.md index 6f95e2c7..2d75c463 100644 --- a/TESTING.md +++ b/TESTING.md @@ -6,7 +6,7 @@ This is the single source of truth for Vera's testing infrastructure, coverage d | Metric | Value | |--------|-------| -| **Tests** | 11,939 across 175 files (~155,000 lines of test code; 11,740 passed + 26 stress, 173 skipped) | +| **Tests** | 11,940 across 175 files (~155,000 lines of test code; 11,741 passed + 26 stress, 173 skipped) | | **Compiler code coverage** | 95% Python, 87% JavaScript (CI minimum: 80%) | | **Conformance programs** | 244 programs across 9 spec chapters, validating every language feature | | **Example programs** | 42, all validated through `vera check` + `vera verify` | @@ -225,7 +225,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_check_doc_counts.py` | 89 | 986 | `check_doc_counts.py`'s pure per-document checks: KNOWN_ISSUES refactoring line counts (±10% tolerance band incl. the exact-boundary case, drift detection, empty-file citation, hyphenated paths, missing file/section/rows, the #419 empty-section sentinel + its cannot-mask-a-malformed-table dual), HISTORY version-row format (issue-link limit, ` — ` separator rejection, dateless-row and prose exemption, line-number reporting), the TESTING.md tests breakdown (parts summing to the collected total, a self-consistent-but-stale row, and the reworded-row error), and vera/README.md's Test Suite counts (all four checked independently — mutation-validated by dropping each citation in turn — plus the reworded-paragraph error, a thousands-separator case pinning that every one of the four counts is read comma-tolerantly, and the two section-anchoring cases — a reworded paragraph with decoy counts in a later section, and a renamed heading, both of which must fail loud rather than match across the section boundary). The reworded case is a test in its own right for both new checks: a pattern that matches nothing must be an error, or rewording the sentence silently switches the gate off. Also the release count (README's status line and HISTORY's total against each other and against `git tag`: the matching case, the one-ahead release cut that `release.yml` has not tagged yet, that +1 being the ONLY slack once the version is tagged, the two-behind drift that actually shipped, per-document reporting, and a tagless checkout standing the oracle down without standing down the cross-check), plus the tag reader itself against real repositories built in `tmp_path` — release tags read, `nightly`/`-rc1` not counted as releases, and both no-evidence answers (`None` rather than `[]`, since an empty list would read as zero releases and make every documented count wrong) for a tagless checkout and a directory that is not a repository at all. CONTRIBUTING.md's pre-commit hook count is checked the same way, reworded-sentence case included; and the CI-pipeline lint row against `ci.yml`'s lint job — a matching row, a step present in CI but absent from the row (the drift that shipped), a row entry CI no longer runs, the same set in a different order, a reworded row and a renamed job (both errors, not skips), that only the lint job is read rather than the whole workflow, and the shipped pair both clean and red with one entry dropped | | `test_grammar_alignment.py` | 85 | 672 | `check_grammar_alignment.py` gate ([#683](https://github.com/aallan/vera/issues/683)): both extractors (Lark headers with the `?`/`!`/`_` markers stripped, template parameters and rule priorities tolerated, and `-> alias` names deliberately not collected; spec headers from ```ebnf fences only), the allowlist arithmetic in all three directions — unwaived drift, a spent entry both files now have, and an entry whose premise broke — one case per waiver proving the fact it rests on is actually checked, the name-deleted-from-both-files case that must not read as agreement, a mutation restoring the spec's old `assert_stmt` name, non-vacuous extraction, and the false positive the issue itself rested on: `qualified_call` and `module_call` are spec headers Lark expresses as aliases, and must never be reported as drift. Three pin the premise checks against ways they used to pass vacuously: an alias surviving only inside a `//` comment must not hold its waiver up, an alias that moved to another rule must fail the waiver naming `fn_call`, and a spent waiver whose premise also broke must yield one instruction rather than two opposite ones | | `test_check_examples_run.py` | 77 | 1,280 | `check_examples_run.py`, the harness gate that RUNS the examples. Five separable parts, each in both directions. **The coverage rule** — the shipped tables cover the shipped corpus exactly, and an unclassified example, a stale `RUN_SPECS` or `SKIPS` key whose file is gone, a name in both tables, and a skip citing an undocumented property are each an error; the empty corpus is an error too, since a glob that stops matching would otherwise report success over zero programs. Plus the specs' own well-formedness: every named entry point is `public` in its example, every no-main example pins one (or `vera run` would fall back to an arbitrary first export), and no skip property is unused. **The runner** — a seeded `tmp_path` corpus proves it goes red on a program that type-checks and compiles but traps at run time, green on one that does not, and reports only the broken member of a mixed pair; a fixture whose first export is clean and whose named one traps proves `spec.fn` is actually honoured rather than ignored; a writer program proves each run gets a scratch working directory, so a gate run leaves nothing beside the examples. **The TESTING.md cross-check** — missing row, extra row, rename (reported naming both sides), wrong disposition and wrong skip property are errors, the parse stops at the next heading so a row-shaped line in a later section is not swept in, and both a reworded heading and a heading whose table has vanished fail loud rather than finding nothing to compare. **The output signal** -- the second half of the two-signal discipline `check_examples.py` established: the fallback note that `vera run` prints when it cannot use the named entry point is a failure even at exit 0, an absent `expect` sentinel is a failure even at exit 0, and a spec without one asserts nothing about output; end to end, a privatised `main` and a program that completed down a graceful arm each go red, and the same program passes once its own output is the sentinel, so the check reads the output rather than always failing. Which specs must carry a sentinel is **derived** from what each example declares — a resource effect in a function's effect row, or a call to a resource operation, both validated against the live effect registry so a renamed effect or op fails loudly — and asserted equal to the specs that have one, in both directions; the previous hard-coded triple could not see a fourth such example arriving without one. The runner's use of BOTH streams is pinned structurally -- `vera run` writes the note to stderr and nothing there on a clean exit, so no fixture can distinguish reading both streams from reading stdout alone, and a tripwire wired to the wrong stream is no tripwire. Also the hermetic-environment property: an ambient `VERA_DB_URL` or provider key is stripped so a gate run cannot be pointed at a real database or turned into a billed API call, a fixture spec puts its own URL back, and every neutralised name is checked to be one `vera/runtime/` actually reads | -| `test_check_corpus_differential.py` | 45 | 742 | `check_corpus_differential.py`, the burndown instrument that compiles the corpus at two revisions. The pure pieces only — the real two-revision run costs minutes and is not a test. **Classification**, all four verdicts: identical, WAT differs, and each one-sided compile failure as its own kind, since a compilability reversal reported as a text difference is the mis-description the instrument exists to avoid; failing at both revisions is not a mover and is counted separately, so a green run states how much of it was vacuous. **Enumeration** — recursive, keyed by repo-relative POSIX path, and an empty corpus is an error rather than a clean run over nothing. **The canary** — each side must import the compiler it was pointed at, so a side silently resolving to the venv's editable install cannot compare a revision against itself; an import failure and a foreign compiler are different messages. **Reporting** — every mover named with its reason, the exit code, the `--json` shape, and a program missing from one side reported rather than dropped. One test asserts the instrument is absent from `.pre-commit-config.yaml`, so the docstring's claim cannot rot | +| `test_check_corpus_differential.py` | 46 | 758 | `check_corpus_differential.py`, the burndown instrument that compiles the corpus at two revisions. The pure pieces only — the real two-revision run costs minutes and is not a test. **Classification**, all four verdicts: identical, WAT differs, and each one-sided compile failure as its own kind, since a compilability reversal reported as a text difference is the mis-description the instrument exists to avoid; failing at both revisions is not a mover and is counted separately, so a green run states how much of it was vacuous. **Enumeration** — recursive, keyed by repo-relative POSIX path, and an empty corpus is an error rather than a clean run over nothing. **The canary** — each side must import the compiler it was pointed at, so a side silently resolving to the venv's editable install cannot compare a revision against itself; an import failure and a foreign compiler are different messages. **Reporting** — every mover named with its reason, the exit code, the `--json` shape, and a program missing from one side reported rather than dropped. One test asserts the instrument is absent from `.pre-commit-config.yaml`, so the docstring's claim cannot rot | | `test_check_editor_grammars.py` | 20 | 249 | `check_editor_grammars.py` gate ([#1156](https://github.com/aallan/vera/issues/1156)): the registry read (every effect in, every ability out, and the four names the grammars actually drifted on present so the set checked is non-vacuous), the word-boundary presence test across all three grammar formats (JSON, plist XML, Vim keyword list) including the prefix pair `Http`/`HttpServer` in both directions, the deliberate comment-mention false pass, a metacharacter pair that only passes when the name is matched literally (`A.B` present, `A0B` not), and the empty registry; the gate's primary path end to end — a listed grammar with an effect stripped out, and a listed README with one stripped out of its prose bullet, each red against an otherwise-clean mirrored tree; the completeness guard — a grammar discovered under `editors/` but absent from `GRAMMARS` fails that same tree, over three discovery routes (`.el` outside a syntax directory, a tree-sitter `.scm` query set, a `.tmLanguage.json` filed anywhere but `syntaxes/`); and the registry's provenance, run as a subprocess against a throwaway checkout whose `vera` package names an effect the grammars do not, which is the only way to see that the list comes from the tree being checked rather than from site-packages. The shipped grammars and READMEs are currently clean | | `test_check_explicit_encoding.py` | 54 | 254 | `check_explicit_encoding.py` gate (#645): flags text-mode `open()` / `read_text()` / `write_text()` **and** `subprocess.run/Popen/check_output(..., text=True)` captures missing an `encoding="utf-8"` literal (rejects non-literal / non-UTF-8 values), skips binary/bytes-mode calls, honours the `# encoding-exempt` opt-out, and asserts the shipped repo is clean | | `test_check_limitations_sync.py` | 6 | 108 | `check_limitations_sync.py` section extraction: table-rows-only issue harvesting, prose-link exemption, bounding at the next second-level heading, `None` for absent or sub-level headings so renamed sections fail loudly; plus the #852 fail-loud rule: an UNKNOWN issue state under `--check-states` (gh missing / auth failure / timeout) is an error, never a silent pass | diff --git a/docs/llms-full.txt b/docs/llms-full.txt index c5bad4f6..8b6d0be0 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -3265,7 +3265,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 11,939 tests, including a 244-program conformance suite +- 11,940 tests, including a 244-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/tests/test_check_corpus_differential.py b/tests/test_check_corpus_differential.py index 773b1566..8a866208 100644 --- a/tests/test_check_corpus_differential.py +++ b/tests/test_check_corpus_differential.py @@ -30,9 +30,9 @@ import argparse import importlib.util +import io import json import subprocess -import sys import threading from pathlib import Path, PureWindowsPath from typing import Any @@ -719,24 +719,40 @@ def fake_run(*args: Any, **kwargs: Any) -> Any: def test_strict_decoding_is_what_would_have_raised(self) -> None: """The reason the kwarg above matters, measured rather than - asserted: the same bytes through the same call raise on strict - and survive on replace.""" - program = "import sys; sys.stdout.buffer.write(b'\\x97')" - command = [sys.executable, "-c", program] + asserted: the same bytes through the same decoder raise on + strict and survive on replace. + + Measured through `io.TextIOWrapper`, which is not a stand-in — + it is the mechanism. `subprocess.Popen` wraps each captured + pipe in exactly this object with exactly the `encoding` and + `errors` it was given, so this reproduces `compile_one`'s + decode without a child process. + + Spawning one was the previous shape and it made the cell + environment-dependent: what a child puts on a pipe depends on + the OS, and the three Windows cells failed here with "DID NOT + RAISE". The decode itself never varied — both calls named + `encoding="utf-8"` — but the byte reaching them did. Note the + byte is only undecodable in UTF-8: `b"\\x97".decode("cp1252")` + is an em dash, so a decode left to the platform default would + not raise on Windows either. Naming the codec is what makes + this deterministic, and it is the same codec the script names. + """ + undecodable = b"\x97" + + def decode(**kwargs: Any) -> str: + return io.TextIOWrapper( + io.BytesIO(undecodable), encoding="utf-8", **kwargs + ).read() + with pytest.raises(UnicodeDecodeError): - subprocess.run( - command, - capture_output=True, - text=True, - encoding="utf-8", - check=False, - ) - lenient = subprocess.run( - command, - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - check=False, - ) - assert lenient.returncode == 0 + decode() + assert decode(errors="replace") == "\ufffd" + + def test_the_byte_is_undecodable_in_the_codec_the_script_names(self) -> None: + """Non-vacuity: the fixture must be undecodable in UTF-8 and not + merely unusual, or the cell above proves nothing about the + codec `compile_one` actually passes.""" + with pytest.raises(UnicodeDecodeError): + b"\x97".decode("utf-8") + assert b"\x97".decode("cp1252") == "\u2014" diff --git a/vera/README.md b/vera/README.md index 60f722aa..33c79773 100644 --- a/vera/README.md +++ b/vera/README.md @@ -753,7 +753,7 @@ The `ERROR_CODES` dict in `errors.py` maps every code to a short description (16 ## Test Suite -Testing spans a **pytest suite** of 11,939 tests across 175 files — compiler-internals unit tests plus a **conformance suite** (244 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. +Testing spans a **pytest suite** of 11,940 tests across 175 files — compiler-internals unit tests plus a **conformance suite** (244 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. See **[TESTING.md](../TESTING.md)** for the comprehensive testing reference -- test file table, conformance suite details, compiler code coverage, language feature coverage, helper conventions, validation scripts, CI pipeline, and guidelines for adding tests. From 00be161f04d336d8d8c640ba4f0a8178703f7836 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Sat, 15 Aug 2026 02:48:28 +0100 Subject: [PATCH 61/69] Say deselected where the sum means deselected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TESTING.md's headline read "11,741 passed + 26 stress, 173 skipped". The three numbers are disjoint and do sum to the collected total, but two of them name a pytest *disposition* and the middle one names a *marker*, which invites reading the 26 as stress tests that passed — and on that reading the sentence's arithmetic is wrong. It is now "26 stress-deselected", so every bucket names a disposition and the sum reads as the partition it is. The compound rather than a plain "deselected" because the reason is worth keeping: the 26 are deselected by `addopts = "-m 'not stress'"`, and a reader reconciling the line against their own `pytest tests/` sees "26 deselected" and now knows which 26 without going to §Stress Tests to find out. The phrase was not the repo-wide convention it looked like. Across the tracked tree it had exactly one live citation — this line — plus the oracle that parses it; it appears in neither CHANGELOG nor HISTORY, so no immutable record is touched. Swept together: the citation, the `_TESTS_BREAKDOWN` pattern, both of `check_tests_breakdown`'s messages, two docstrings, and the fixture builder in the tests. The negative fixture that proves the matches-nothing guard fires keeps its own deliberately different wording. The gate is unchanged in strength and still discriminating: each of the three figures moved by one goes red on its own, and the old wording is now caught by the matches-nothing guard rather than passing silently. Co-Authored-By: Claude --- TESTING.md | 4 ++-- scripts/check_doc_counts.py | 21 ++++++++++++++++----- tests/test_check_doc_counts.py | 5 +++-- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/TESTING.md b/TESTING.md index 2d75c463..49724c24 100644 --- a/TESTING.md +++ b/TESTING.md @@ -6,7 +6,7 @@ This is the single source of truth for Vera's testing infrastructure, coverage d | Metric | Value | |--------|-------| -| **Tests** | 11,940 across 175 files (~155,000 lines of test code; 11,741 passed + 26 stress, 173 skipped) | +| **Tests** | 11,940 across 175 files (~155,000 lines of test code; 11,741 passed + 26 stress-deselected, 173 skipped) | | **Compiler code coverage** | 95% Python, 87% JavaScript (CI minimum: 80%) | | **Conformance programs** | 244 programs across 9 spec chapters, validating every language feature | | **Example programs** | 42, all validated through `vera check` + `vera verify` | @@ -222,7 +222,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_builtin_typevar_collision_970.py` | 61 | 811 | #970 a user `forall` type-var name colliding with a built-in generic's internal name (`T`/`E`/`A`/`B`/`K`/`U`/`V`): focused check/verify pins for the compound-argument shapes (`@Array>`, `@Result>`, `@Map>`) plus a collide-vs-control differential battery over every generic-builtin family and contract/where-helper position. Also pins marker-strip (the `#b` namespacing marker must never reach an E205/E202 diagnostic), a registry-consistency pin (every built-in ability-constraint `type_var` stays a member of its `forall_vars`), the dual completeness-gap pinned in both argument orders, a tier-split equality pin, and the #1069 leaked-placeholder message-rendering sweep (a stripped built-in var renders as `?`, not a bare letter, at every reachable actual-type slot: the mismatch sites plus the operator/index/interpolation family, `assert`/`assume`, `if` condition and branches, and the contract/refinement predicates — one parametrized row per converted render slot, with the provably-unreachable sites documented in the class docstring) | | `test_check_changelog_updated.py` | 68 | 712 | `check_changelog_updated.py` unit + end-to-end tests: file classification (incl. file-style exact-match vs directory-style prefix-match), CHANGELOG diff parsing with `[Unreleased]` section tracking, bare-heading rejection, and full-file context (regression test for bullets far below the heading), `Skip-changelog:` trailer detection, temp-repo integration covering substantive/exempt/label/trailer paths, and `GIT_*`-env hermeticity of the temp-repo fixtures (regression for the pre-commit-hook env leak) | | `test_release.py` | 61 | 757 | Release policy and registry verification (#481): strict project-name and version parsing/comparison, version-bump/TestPyPI/recovery planning, exact confirmation and immutable-tag guards, first-parent version-introduction discovery, package-change recovery refusal, non-empty CHANGELOG extraction, one-wheel/one-sdist SHA-256 manifests, malformed registry-response handling, missing/filename/hash propagation retries, exact filename/hash verification, and CLI dispatch/GitHub-output wiring. An autouse fixture scrubs hook-exported `GIT_*` variables so the tmp-repo git calls (fixture helpers and `release.py`'s own) never resolve to the developer's repository when the suite runs inside a pre-commit hook. | -| `test_check_doc_counts.py` | 89 | 986 | `check_doc_counts.py`'s pure per-document checks: KNOWN_ISSUES refactoring line counts (±10% tolerance band incl. the exact-boundary case, drift detection, empty-file citation, hyphenated paths, missing file/section/rows, the #419 empty-section sentinel + its cannot-mask-a-malformed-table dual), HISTORY version-row format (issue-link limit, ` — ` separator rejection, dateless-row and prose exemption, line-number reporting), the TESTING.md tests breakdown (parts summing to the collected total, a self-consistent-but-stale row, and the reworded-row error), and vera/README.md's Test Suite counts (all four checked independently — mutation-validated by dropping each citation in turn — plus the reworded-paragraph error, a thousands-separator case pinning that every one of the four counts is read comma-tolerantly, and the two section-anchoring cases — a reworded paragraph with decoy counts in a later section, and a renamed heading, both of which must fail loud rather than match across the section boundary). The reworded case is a test in its own right for both new checks: a pattern that matches nothing must be an error, or rewording the sentence silently switches the gate off. Also the release count (README's status line and HISTORY's total against each other and against `git tag`: the matching case, the one-ahead release cut that `release.yml` has not tagged yet, that +1 being the ONLY slack once the version is tagged, the two-behind drift that actually shipped, per-document reporting, and a tagless checkout standing the oracle down without standing down the cross-check), plus the tag reader itself against real repositories built in `tmp_path` — release tags read, `nightly`/`-rc1` not counted as releases, and both no-evidence answers (`None` rather than `[]`, since an empty list would read as zero releases and make every documented count wrong) for a tagless checkout and a directory that is not a repository at all. CONTRIBUTING.md's pre-commit hook count is checked the same way, reworded-sentence case included; and the CI-pipeline lint row against `ci.yml`'s lint job — a matching row, a step present in CI but absent from the row (the drift that shipped), a row entry CI no longer runs, the same set in a different order, a reworded row and a renamed job (both errors, not skips), that only the lint job is read rather than the whole workflow, and the shipped pair both clean and red with one entry dropped | +| `test_check_doc_counts.py` | 89 | 987 | `check_doc_counts.py`'s pure per-document checks: KNOWN_ISSUES refactoring line counts (±10% tolerance band incl. the exact-boundary case, drift detection, empty-file citation, hyphenated paths, missing file/section/rows, the #419 empty-section sentinel + its cannot-mask-a-malformed-table dual), HISTORY version-row format (issue-link limit, ` — ` separator rejection, dateless-row and prose exemption, line-number reporting), the TESTING.md tests breakdown (parts summing to the collected total, a self-consistent-but-stale row, and the reworded-row error), and vera/README.md's Test Suite counts (all four checked independently — mutation-validated by dropping each citation in turn — plus the reworded-paragraph error, a thousands-separator case pinning that every one of the four counts is read comma-tolerantly, and the two section-anchoring cases — a reworded paragraph with decoy counts in a later section, and a renamed heading, both of which must fail loud rather than match across the section boundary). The reworded case is a test in its own right for both new checks: a pattern that matches nothing must be an error, or rewording the sentence silently switches the gate off. Also the release count (README's status line and HISTORY's total against each other and against `git tag`: the matching case, the one-ahead release cut that `release.yml` has not tagged yet, that +1 being the ONLY slack once the version is tagged, the two-behind drift that actually shipped, per-document reporting, and a tagless checkout standing the oracle down without standing down the cross-check), plus the tag reader itself against real repositories built in `tmp_path` — release tags read, `nightly`/`-rc1` not counted as releases, and both no-evidence answers (`None` rather than `[]`, since an empty list would read as zero releases and make every documented count wrong) for a tagless checkout and a directory that is not a repository at all. CONTRIBUTING.md's pre-commit hook count is checked the same way, reworded-sentence case included; and the CI-pipeline lint row against `ci.yml`'s lint job — a matching row, a step present in CI but absent from the row (the drift that shipped), a row entry CI no longer runs, the same set in a different order, a reworded row and a renamed job (both errors, not skips), that only the lint job is read rather than the whole workflow, and the shipped pair both clean and red with one entry dropped | | `test_grammar_alignment.py` | 85 | 672 | `check_grammar_alignment.py` gate ([#683](https://github.com/aallan/vera/issues/683)): both extractors (Lark headers with the `?`/`!`/`_` markers stripped, template parameters and rule priorities tolerated, and `-> alias` names deliberately not collected; spec headers from ```ebnf fences only), the allowlist arithmetic in all three directions — unwaived drift, a spent entry both files now have, and an entry whose premise broke — one case per waiver proving the fact it rests on is actually checked, the name-deleted-from-both-files case that must not read as agreement, a mutation restoring the spec's old `assert_stmt` name, non-vacuous extraction, and the false positive the issue itself rested on: `qualified_call` and `module_call` are spec headers Lark expresses as aliases, and must never be reported as drift. Three pin the premise checks against ways they used to pass vacuously: an alias surviving only inside a `//` comment must not hold its waiver up, an alias that moved to another rule must fail the waiver naming `fn_call`, and a spent waiver whose premise also broke must yield one instruction rather than two opposite ones | | `test_check_examples_run.py` | 77 | 1,280 | `check_examples_run.py`, the harness gate that RUNS the examples. Five separable parts, each in both directions. **The coverage rule** — the shipped tables cover the shipped corpus exactly, and an unclassified example, a stale `RUN_SPECS` or `SKIPS` key whose file is gone, a name in both tables, and a skip citing an undocumented property are each an error; the empty corpus is an error too, since a glob that stops matching would otherwise report success over zero programs. Plus the specs' own well-formedness: every named entry point is `public` in its example, every no-main example pins one (or `vera run` would fall back to an arbitrary first export), and no skip property is unused. **The runner** — a seeded `tmp_path` corpus proves it goes red on a program that type-checks and compiles but traps at run time, green on one that does not, and reports only the broken member of a mixed pair; a fixture whose first export is clean and whose named one traps proves `spec.fn` is actually honoured rather than ignored; a writer program proves each run gets a scratch working directory, so a gate run leaves nothing beside the examples. **The TESTING.md cross-check** — missing row, extra row, rename (reported naming both sides), wrong disposition and wrong skip property are errors, the parse stops at the next heading so a row-shaped line in a later section is not swept in, and both a reworded heading and a heading whose table has vanished fail loud rather than finding nothing to compare. **The output signal** -- the second half of the two-signal discipline `check_examples.py` established: the fallback note that `vera run` prints when it cannot use the named entry point is a failure even at exit 0, an absent `expect` sentinel is a failure even at exit 0, and a spec without one asserts nothing about output; end to end, a privatised `main` and a program that completed down a graceful arm each go red, and the same program passes once its own output is the sentinel, so the check reads the output rather than always failing. Which specs must carry a sentinel is **derived** from what each example declares — a resource effect in a function's effect row, or a call to a resource operation, both validated against the live effect registry so a renamed effect or op fails loudly — and asserted equal to the specs that have one, in both directions; the previous hard-coded triple could not see a fourth such example arriving without one. The runner's use of BOTH streams is pinned structurally -- `vera run` writes the note to stderr and nothing there on a clean exit, so no fixture can distinguish reading both streams from reading stdout alone, and a tripwire wired to the wrong stream is no tripwire. Also the hermetic-environment property: an ambient `VERA_DB_URL` or provider key is stripped so a gate run cannot be pointed at a real database or turned into a billed API call, a fixture spec puts its own URL back, and every neutralised name is checked to be one `vera/runtime/` actually reads | | `test_check_corpus_differential.py` | 46 | 758 | `check_corpus_differential.py`, the burndown instrument that compiles the corpus at two revisions. The pure pieces only — the real two-revision run costs minutes and is not a test. **Classification**, all four verdicts: identical, WAT differs, and each one-sided compile failure as its own kind, since a compilability reversal reported as a text difference is the mis-description the instrument exists to avoid; failing at both revisions is not a mover and is counted separately, so a green run states how much of it was vacuous. **Enumeration** — recursive, keyed by repo-relative POSIX path, and an empty corpus is an error rather than a clean run over nothing. **The canary** — each side must import the compiler it was pointed at, so a side silently resolving to the venv's editable install cannot compare a revision against itself; an import failure and a foreign compiler are different messages. **Reporting** — every mover named with its reason, the exit code, the `--json` shape, and a program missing from one side reported rather than dropped. One test asserts the instrument is absent from `.pre-commit-config.yaml`, so the docstring's claim cannot rot | diff --git a/scripts/check_doc_counts.py b/scripts/check_doc_counts.py index 24f18cf5..61fd41a0 100644 --- a/scripts/check_doc_counts.py +++ b/scripts/check_doc_counts.py @@ -5,7 +5,7 @@ files, pre-commit hooks, CI jobs) and pytest-collection counts (total tests, per-file test counts and line counts) against the numbers written in TESTING.md, CONTRIBUTING.md, CLAUDE.md, README.md, SKILL.md, AGENTS.md, -FAQ.md, and ROADMAP.md. Also checks TESTING.md's passed/stress/skipped +FAQ.md, and ROADMAP.md. Also checks TESTING.md's passed/stress-deselected/skipped breakdown against the collected total, the KNOWN_ISSUES.md "Refactoring needed" line counts (±10% tolerance), the HISTORY.md version-row format (one issue link max, no " — " separator per row), the vera/README.md @@ -88,15 +88,24 @@ def check_refactoring_counts(known_issues_text: str, root: Path) -> list[str]: _TESTS_BREAKDOWN = re.compile( r"\*\*Tests\*\*\s*\|\s*[\d,]+\s+across.*?;\s*([\d,]+) passed" - r"\s*\+\s*([\d,]+) stress,\s*([\d,]+) skipped" + r"\s*\+\s*([\d,]+) stress-deselected,\s*([\d,]+) skipped" ) def check_tests_breakdown(testing_text: str, live_total: int) -> list[str]: """Check that TESTING.md's tests breakdown sums to the gated total. + All three parts name a pytest *disposition*, which is what makes the + sum readable: the 26 are deselected before the run by + ``addopts = "-m 'not stress'"``, so they are disjoint from the passed + count rather than a subset of it. Naming the marker alone — "26 + stress" beside "passed" and "skipped" — invited reading them as + stress tests that passed, which would make the sentence's arithmetic + wrong (PR #1329 review). + The overview row states the total *and* its parts, in the shape - "1,306 across 40 files (…; 1,234 passed + 5 stress, 67 skipped)" — + "1,306 across 40 files (…; 1,234 passed + 5 stress-deselected, 67 + skipped)" — illustrative numbers, so this docstring does not itself become a citation to keep in sync. Pinning the total alone leaves the parts free to drift, so a release that moves the parts without moving the @@ -115,7 +124,8 @@ def check_tests_breakdown(testing_text: str, live_total: int) -> list[str]: if m is None: return [ "TESTING.md: no tests breakdown matched" - " ('N passed + N stress, N skipped') — the row moved or was" + " ('N passed + N stress-deselected, N skipped') — the row" + " moved or was" " reworded, so the breakdown is no longer gated" ] parts = [int(g.replace(",", "")) for g in m.groups()] @@ -124,7 +134,8 @@ def check_tests_breakdown(testing_text: str, live_total: int) -> list[str]: passed, stress, skipped = parts return [ f"TESTING.md tests breakdown: {passed:,} passed" - f" + {stress:,} stress + {skipped:,} skipped = {total:,}," + f" + {stress:,} stress-deselected + {skipped:,} skipped" + f" = {total:,}," f" but the collected total is {live_total:,}" ] return [] diff --git a/tests/test_check_doc_counts.py b/tests/test_check_doc_counts.py index 2a3c016a..077f7f0b 100644 --- a/tests/test_check_doc_counts.py +++ b/tests/test_check_doc_counts.py @@ -7,7 +7,7 @@ line counts must stay within ±10% of the measured file sizes. - ``check_history_row_format`` — HISTORY.md version rows carry at most one issue link and no " — " separator. -- ``check_tests_breakdown`` — TESTING.md's passed/stress/skipped parts +- ``check_tests_breakdown`` — TESTING.md's passed/stress-deselected/skipped parts must sum to the collected total. - ``check_vera_readme_test_counts`` — the four counts in vera/README.md's Test Suite paragraph. @@ -208,7 +208,8 @@ def _overview(passed: int, stress: int, skipped: int, total: int) -> str: "| Metric | Value |\n" "|--------|-------|\n" f"| **Tests** | {total:,} across 143 files (~108,000 lines of test" - f" code; {passed:,} passed + {stress} stress, {skipped} skipped) |\n" + f" code; {passed:,} passed + {stress} stress-deselected," + f" {skipped} skipped) |\n" ) From ec85f25be44daf3c3e63fe80e16b2670956a826a Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Sat, 15 Aug 2026 04:11:49 +0100 Subject: [PATCH 62/69] Cut v0.1.12 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twelve work groups, closing the burndown that ran across refusal rails, the JSON accept domain, throw-payload guards, branch-join monomorphization, and the release and spec-drift gates. Version 0.1.12 across the `check_version_sync.py` surface, with `uv.lock` regenerated by `uv lock`. The accumulated `[Unreleased]` notes become `## [0.1.12] - 2026-08-15` with a fresh empty section above them and the two compare links updated; every released section below is byte-identical, 1,021,781 bytes of them. One HISTORY row in the current stage table. ROADMAP gains the v0.1.13 burndown as an explicit queue rather than leaving the next release's scope implicit in KNOWN_ISSUES: the fourteen carried bugs in order of attack, the set checked equal to the Bugs table's own. CONTRIBUTING states the fenced-example convention — the `check_*_examples` family replays every documentation fence through the compiler, and `examples/` is type-checked, verified and run — which is what the script registry actually does, parse being the floor and the spec, landing page and PyPI readme going the whole pipeline. The release notes are 85,730 characters: under the 120,000 budget, so `release.py notes` publishes the section verbatim and the #1288 condenser stays out of the way. Co-Authored-By: Claude --- CHANGELOG.md | 5 ++++- CONTRIBUTING.md | 2 ++ HISTORY.md | 3 ++- README.md | 2 +- ROADMAP.md | 23 +++++++++++++++++++++++ docs/index.html | 2 +- docs/index.md | 2 +- docs/llms-full.txt | 2 +- docs/llms.txt | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- vera/__init__.py | 2 +- 12 files changed, 39 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36070cb7..3ff54831 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [0.1.12] - 2026-08-15 + ### Added - **The examples are now RUN in CI, not only checked, verified and compiled** (`scripts/check_examples_run.py`). `check_examples.py` type-checks and verifies all 42, and `check_e602_clean.py` compiles all 42 as a side effect of policing silent translator skips — but nothing executed them as a set, and an audit of every referencing test found **seventeen examples that no test ran at all**: `array_utilities`, `async_http_fanout`, `collections`, `database`, `fizzbuzz`, `html`, `http`, `inference`, `io_operations`, `json`, `life`, `maximum_syntax`, `modules`, `nested_closures`, `read_char`, `scoreboard` and `string_utilities`, plus `file_io`, which ran only under the browser runtime, where the file IO it demonstrates is a deliberate `Err` stub. Between them they demonstrate `Map`/`Set`, the `` effect, JSON and HTML parsing, module imports and the whole string-utility family, so a runtime regression in any of it could reach a release with every gate green. The gate now runs 34 of the 42 under the native runtime and asserts a trap-free exit; the other 8 carry a documented skip property (`network`, `api-key`, `stdin`, `non-scalar-entry`, `long-running`) which the report prints with its reason on every run. Output pinning deliberately stays in the dedicated tests that already do it, so the gate does not go red on a cosmetic edit to an example. The load-bearing part is not the runs but the **coverage rule**: the script enumerates `examples/*.vera` from disk and requires every name to be in exactly one of its two tables, so an unclassified example is an error and adding one forces the author to decide whether the harness can drive it — and a table key whose file is gone is an error too, so a suppression cannot outlive its example and mask a later program of the same name. The classification is cross-checked against a new execution-coverage table in `TESTING.md` on the `check_doc_counts.py` model, the codebase being the oracle and the documentation having to match it, so the execution model stops living in maintainers' heads. Trap-freedom is asserted on two signals, the discipline `check_examples.py` already applies: the exit code, and an output signal. Either alone accepts a measured failure. Every spec names its entry point rather than relying on `vera run`'s first-export fallback — with `main` privatised, `array_utilities.vera` ran a different function and the gate passed; it now exits 1 on the name. And the three examples that reach outside the process (`sqlitedb.vera` for its committed fixture, `database.vera` for an in-memory database, `file_io.vera` for the filesystem) answer a failure by printing a message and completing normally, so each pins a substring only its success path prints — deleting `examples/sqlitedb.sqlite` left the gate green on the graceful in-memory arm, and now fails on the sentinel. Runs are hermetic: an ambient `VERA_DB_URL` or inference-provider key is stripped from the environment, so a gate run cannot be pointed at a real database or turned into a billed API request, and each example gets a scratch working directory so `file_io.vera` stops dropping `hello.txt` beside the sources. `TESTING.md`'s round-trip section is corrected with them — it claimed all 42 examples were tested through "every pipeline stage ... WASM compilation, and execution", where the directory-globbing parametrised tests in fact stop at verification and canonical form. @@ -3377,7 +3379,8 @@ Small docs sweep — closes six aging documentation issues in one PR. No code c - Grammar: handler body simplified to avoid LALR reduce/reduce conflict - `pyproject.toml`: corrected build backend, package discovery, PEP 639 compliance -[Unreleased]: https://github.com/aallan/vera/compare/v0.1.11...HEAD +[Unreleased]: https://github.com/aallan/vera/compare/v0.1.12...HEAD +[0.1.12]: https://github.com/aallan/vera/compare/v0.1.11...v0.1.12 [0.1.11]: https://github.com/aallan/vera/compare/v0.1.10...v0.1.11 [0.1.10]: https://github.com/aallan/vera/compare/v0.1.9...v0.1.10 [0.1.9]: https://github.com/aallan/vera/compare/v0.1.8...v0.1.9 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 081edd13..7effcd76 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -165,6 +165,8 @@ mypy vera/ ### Validation Scripts +Every Vera code block in the documentation is gated: the `check_*_examples` family replays each fence through the compiler — parsing at minimum, and for the spec, `docs/index.html` and `PYPI_README.md` the whole pipeline — so a fence cannot drift from the language it demonstrates. The `examples/` corpus is held harder still: `check_examples.py` type-checks and verifies all of it, and `check_examples_run.py` runs it, so an example is gated as a program and not merely as text. + ```bash python scripts/check_conformance.py # verify all conformance programs python scripts/check_examples.py # verify all .vera examples diff --git a/HISTORY.md b/HISTORY.md index 5a7d0300..4a0f6ba0 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -477,6 +477,7 @@ Stages 19 and 20 run dual-threaded: community PRs against the single-source spri | v0.1.9 | 4 Aug | **The declarable-trap purge** — the reserved-name family completes under E153 ([#1187](https://github.com/aallan/vera/issues/1187)). | | v0.1.10 | 12 Aug | **The handler-machinery consolidation** — 37 bugs fixed by giving each fact one derivation ([#1213](https://github.com/aallan/vera/issues/1213)). | | v0.1.11 | 13 Aug | **The community-PR queue clears** — seven third-party contributions reviewed and merged. | +| v0.1.12 | 15 Aug | **The twelve-group burndown** — refusal rails, the JSON accept domain, throw-payload guards, branch-join monomorphization, and release and spec-drift gates. | --- @@ -497,4 +498,4 @@ Ten releases, chosen for the capability each one unlocked rather than even spaci | Spec chapters | 7 | 10 | 12 | 13 | 13 | 13 | 13 | 14 | 14 | 14 | | Python coverage | — | — | 90% | 96% | 95% | 95% | 95% | 95% | 95% | 95% | -Total: **2,000+ commits, 209 tagged releases, 103 active development days.** +Total: **2,000+ commits, 210 tagged releases, 103 active development days.** diff --git a/README.md b/README.md index fe40b36b..4d94e263 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ cp /path/to/vera/SKILL.md ~/.claude/skills/vera-language/SKILL.md ## Project status -Vera is in **active development** at v0.1.11: 2,000+ commits, 209 releases, 11,940 tests, 95% Python code coverage, 244 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. +Vera is in **active development** at v0.1.12: 2,000+ commits, 210 releases, 11,940 tests, 95% Python code coverage, 244 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. The reference compiler — parser, AST, type checker, contract verifier (Z3), WASM code generator, module system, browser runtime, and runtime contract insertion — is working. The language specification is in draft across [14 chapters](spec/). diff --git a/ROADMAP.md b/ROADMAP.md index d3e744e5..e42fe983 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -14,6 +14,29 @@ Ordering derives from the design principles ([DESIGN.md](DESIGN.md)): verificati 11,940 tests, 244 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. +## The v0.1.13 burndown + +*Fourteen open bugs, driven to zero.* + +A bug class outranks stage work, so the next release takes the open `bug`-labelled set as its queue. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) carries each row's full account and stays the one place the detail lives; this table is the order of attack. + +| Issue | What | +|---|---| +| [#1315](https://github.com/aallan/vera/issues/1315) | The checker accepts a constructor pattern over a container ADT that has no constructors. | +| [#1320](https://github.com/aallan/vera/issues/1320) | The checker admits literal patterns over scrutinees that can never match them — the general disease behind #1315. | +| [#1316](https://github.com/aallan/vera/issues/1316) | A main-file `type` alias named after a prelude ADT leaks into the prelude's own bodies. | +| [#1317](https://github.com/aallan/vera/issues/1317) | `E609` and `E610` refuse two modules' same-named declarations by declaration rather than by use. | +| [#1312](https://github.com/aallan/vera/issues/1312) | An entry-file `data` declaration and a module's of the same name silently drop the caller when their shapes differ. | +| [#1321](https://github.com/aallan/vera/issues/1321) | Codegen tests its container branches before `_adt_layouts`, so a user `data` named after a container is misclassified. | +| [#1327](https://github.com/aallan/vera/issues/1327) | Monomorphization's type namer has no `IndexExpr` arm, so a generic argument that is an array index drops its caller. | +| [#1310](https://github.com/aallan/vera/issues/1310) | A module generic instantiated at an effect-operation result type never registers its clone. | +| [#1307](https://github.com/aallan/vera/issues/1307) | The checker resolves a bare call to a sibling function's `where` helper. | +| [#1298](https://github.com/aallan/vera/issues/1298) | A postcondition may name a `State` the function's effect row never declares. | +| [#1322](https://github.com/aallan/vera/issues/1322) | A match binding's GC shadow-stack push is never popped, so the stack grows with recursion depth until it traps. | +| [#1301](https://github.com/aallan/vera/issues/1301) | `md_parse` diverges between the native and browser runtimes on adversarial input. | +| [#1328](https://github.com/aallan/vera/issues/1328) | The nightly stress workflow never exercises the host-handle reclamation battery. | +| [#996](https://github.com/aallan/vera/issues/996) | `ch05_closure_nat_return` trapped once in a full conformance run and has not reproduced. | + ## Stage 19 — The verification completeness sprint *`vera verify` tells the whole truth.* diff --git a/docs/index.html b/docs/index.html index 433fb0a2..603293da 100644 --- a/docs/index.html +++ b/docs/index.html @@ -262,7 +262,7 @@

A programming language designed for LLMs to write, not For Agents → SKILL.md

- v0.1.11 + v0.1.12 CI

diff --git a/docs/index.md b/docs/index.md index e7b2b281..cb556830 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,7 @@ From the Latin *veritas* — truth. In Vera, verification is a first-class citizen. -**Current version:** [0.1.11](https://github.com/aallan/vera/releases/tag/v0.1.11) · [GitHub](https://github.com/aallan/vera) · [SKILL.md](https://veralang.dev/SKILL.md) (agent language reference) +**Current version:** [0.1.12](https://github.com/aallan/vera/releases/tag/v0.1.12) · [GitHub](https://github.com/aallan/vera) · [SKILL.md](https://veralang.dev/SKILL.md) (agent language reference) ## Why? diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 8b6d0be0..7f3afe7b 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -2,7 +2,7 @@ > Vera is a statically typed, purely functional programming language designed for large language models to write. It uses typed slot references (@T.n) instead of variable names, requires contracts on every function, and compiles to WebAssembly. -This file contains the core Vera language documentation — language reference, agent instructions, FAQ, error codes, and formal grammar — compiled into a single document. Version 0.1.11. For the full documentation index including the 14-chapter specification and supplementary docs, see llms.txt. +This file contains the core Vera language documentation — language reference, agent instructions, FAQ, error codes, and formal grammar — compiled into a single document. Version 0.1.12. For the full documentation index including the 14-chapter specification and supplementary docs, see llms.txt. ======================================================================== diff --git a/docs/llms.txt b/docs/llms.txt index 2989765e..85cf1309 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -4,7 +4,7 @@ Vera uses De Bruijn indexing for bindings: `@Int.0` is the most recent `Int` binding, `@Int.1` the one before. There are no variable names. Contracts are mandatory — every function must declare `requires(...)`, `ensures(...)`, and `effects(...)`. The Z3 SMT solver verifies contracts statically where possible; remaining contracts become runtime assertions. All side effects (IO, Http, HttpServer, State, Exceptions, Async, Inference, DB, Random, Diverge) are tracked in the type system via algebraic effects. -Current version: 0.1.11. The reference compiler is written in Python. Install the `veralang` distribution from PyPI or use `pip install -e ".[dev]"` from the repository. +Current version: 0.1.12. The reference compiler is written in Python. Install the `veralang` distribution from PyPI or use `pip install -e ".[dev]"` from the repository. ## Homepage diff --git a/pyproject.toml b/pyproject.toml index 05b31f3c..abc050b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "veralang" -version = "0.1.11" +version = "0.1.12" description = "Vera: a programming language designed for LLMs, with full contracts, algebraic effects, and typed slot references" readme = "PYPI_README.md" license = "MIT" diff --git a/uv.lock b/uv.lock index ed492326..917ce7c8 100644 --- a/uv.lock +++ b/uv.lock @@ -1544,7 +1544,7 @@ wheels = [ [[package]] name = "veralang" -version = "0.1.11" +version = "0.1.12" source = { editable = "." } dependencies = [ { name = "lark" }, diff --git a/vera/__init__.py b/vera/__init__.py index 524e17db..b5a3c0d5 100644 --- a/vera/__init__.py +++ b/vera/__init__.py @@ -1,4 +1,4 @@ """Vera: a programming language designed for LLMs.""" -__version__ = "0.1.11" +__version__ = "0.1.12" version = __version__ From 3ebe7b28bce7ebf66f4e28bb68af761788bcafe2 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Sat, 15 Aug 2026 05:01:44 +0100 Subject: [PATCH 63/69] Close the release-PR review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four fixes and one refutation, each verified against the code first. The base checkout is reused across runs, and the reuse branch proved only that it sits at the right commit — never that its tree is clean. An edit made under a persistent checkout survives to the next run and silently becomes the base compiler; the canary cannot object, because it proves which checkout was imported and a modified one is still that checkout. A dirty base makes "0 movers" mean nothing and can invent movers out of the edit, which is the one failure this instrument must not have — and the refusal message has always promised "a clean checkout", so the probe makes the text true rather than adding a new claim. Four cells: a dirty tree refused, an untracked file refused, a clean reuse still accepted, and a non-vacuity cell proving the dirty tree is still at the right commit, so the pre-existing check would have waved it through. `condense_notes` triggers at the 120,000-character budget but its preamble said the notes were "past GitHub's 125,000-character limit". For a section between the two — the band this release very nearly landed in at 85,730 — that sentence is a falsehood published verbatim in the release body. It now names the budget that actually fired and states GitHub's limit as the separate fact it is, with a cell in the band. `_symbols` blanked quoted literals for the terminal half only, so a Lark literal spelling a lowercase word counted as a reference to a rule of that name. Latent — no literal collides with a rule name today — which is why it needed unit cells rather than the shipped files. `build_command` always passes `--fn`, so `vera run` refuses an unresolvable entry point instead of falling back to the first export, and the `FALLBACK_NOTE` check cannot fire through the live path. The real path now has an end-to-end cell, `--fn` being always passed has one of its own, and the note check stays as the backstop for a `build_command` that stops passing it, saying so. Not taken: the `sqlitedb` fixture copy. Measured, running the example exactly as the gate does leaves no side file beside the fixture — the database is `journal_mode=delete` and the example only reads, so no journal or WAL is ever created. The general SQLite fact is right; this program does not trigger it. Co-Authored-By: Claude --- FAQ.md | 2 +- README.md | 2 +- ROADMAP.md | 2 +- TESTING.md | 10 +-- docs/llms-full.txt | 2 +- scripts/check_corpus_differential.py | 20 +++++ scripts/check_examples_run.py | 7 ++ scripts/check_grammar_alignment.py | 16 +++- scripts/release.py | 11 ++- tests/test_check_corpus_differential.py | 103 ++++++++++++++++++++++++ tests/test_check_examples_run.py | 43 +++++++++- tests/test_grammar_alignment.py | 34 ++++++++ tests/test_release.py | 31 ++++++- vera/README.md | 2 +- 14 files changed, 265 insertions(+), 20 deletions(-) diff --git a/FAQ.md b/FAQ.md index 5e920fbc..963afe57 100644 --- a/FAQ.md +++ b/FAQ.md @@ -279,7 +279,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 11,940 tests, including a 244-program conformance suite +- 11,951 tests, including a 244-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/README.md b/README.md index 4d94e263..205a0123 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ cp /path/to/vera/SKILL.md ~/.claude/skills/vera-language/SKILL.md ## Project status -Vera is in **active development** at v0.1.12: 2,000+ commits, 210 releases, 11,940 tests, 95% Python code coverage, 244 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. +Vera is in **active development** at v0.1.12: 2,000+ commits, 210 releases, 11,951 tests, 95% Python code coverage, 244 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. The reference compiler — parser, AST, type checker, contract verifier (Z3), WASM code generator, module system, browser runtime, and runtime contract insertion — is working. The language specification is in draft across [14 chapters](spec/). diff --git a/ROADMAP.md b/ROADMAP.md index e42fe983..257c0938 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,7 +12,7 @@ Ordering derives from the design principles ([DESIGN.md](DESIGN.md)): verificati ## Where we are -11,940 tests, 244 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. +11,951 tests, 244 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. ## The v0.1.13 burndown diff --git a/TESTING.md b/TESTING.md index 49724c24..16d95536 100644 --- a/TESTING.md +++ b/TESTING.md @@ -6,7 +6,7 @@ This is the single source of truth for Vera's testing infrastructure, coverage d | Metric | Value | |--------|-------| -| **Tests** | 11,940 across 175 files (~155,000 lines of test code; 11,741 passed + 26 stress-deselected, 173 skipped) | +| **Tests** | 11,951 across 175 files (~155,000 lines of test code; 11,752 passed + 26 stress-deselected, 173 skipped) | | **Compiler code coverage** | 95% Python, 87% JavaScript (CI minimum: 80%) | | **Conformance programs** | 244 programs across 9 spec chapters, validating every language feature | | **Example programs** | 42, all validated through `vera check` + `vera verify` | @@ -221,11 +221,11 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_build_site.py` | 46 | 698 | Site-asset tooling — `_abs_links` rewriting (relative links, fenced-block immunity incl. inline backticks and tilde fences, http/https/fragment pass-through, Vera effect syntax not mis-parsed), `build_site` `` stability (preserve/refresh keyed on URL-structure change), `check_site_assets` sitemap staleness (missing / date-only-clean / structural-stale), and the #538 leak guard (vera:skip fence annotations stripped from generated `docs/SKILL.md` / `docs/llms-full.txt`, with a non-vacuous precondition that the source carries annotations); plus the #1154 `check_fact_coherence()` suite — index.html↔index.md fact extraction and divergence detection | | `test_builtin_typevar_collision_970.py` | 61 | 811 | #970 a user `forall` type-var name colliding with a built-in generic's internal name (`T`/`E`/`A`/`B`/`K`/`U`/`V`): focused check/verify pins for the compound-argument shapes (`@Array>`, `@Result>`, `@Map>`) plus a collide-vs-control differential battery over every generic-builtin family and contract/where-helper position. Also pins marker-strip (the `#b` namespacing marker must never reach an E205/E202 diagnostic), a registry-consistency pin (every built-in ability-constraint `type_var` stays a member of its `forall_vars`), the dual completeness-gap pinned in both argument orders, a tier-split equality pin, and the #1069 leaked-placeholder message-rendering sweep (a stripped built-in var renders as `?`, not a bare letter, at every reachable actual-type slot: the mismatch sites plus the operator/index/interpolation family, `assert`/`assume`, `if` condition and branches, and the contract/refinement predicates — one parametrized row per converted render slot, with the provably-unreachable sites documented in the class docstring) | | `test_check_changelog_updated.py` | 68 | 712 | `check_changelog_updated.py` unit + end-to-end tests: file classification (incl. file-style exact-match vs directory-style prefix-match), CHANGELOG diff parsing with `[Unreleased]` section tracking, bare-heading rejection, and full-file context (regression test for bullets far below the heading), `Skip-changelog:` trailer detection, temp-repo integration covering substantive/exempt/label/trailer paths, and `GIT_*`-env hermeticity of the temp-repo fixtures (regression for the pre-commit-hook env leak) | -| `test_release.py` | 61 | 757 | Release policy and registry verification (#481): strict project-name and version parsing/comparison, version-bump/TestPyPI/recovery planning, exact confirmation and immutable-tag guards, first-parent version-introduction discovery, package-change recovery refusal, non-empty CHANGELOG extraction, one-wheel/one-sdist SHA-256 manifests, malformed registry-response handling, missing/filename/hash propagation retries, exact filename/hash verification, and CLI dispatch/GitHub-output wiring. An autouse fixture scrubs hook-exported `GIT_*` variables so the tmp-repo git calls (fixture helpers and `release.py`'s own) never resolve to the developer's repository when the suite runs inside a pre-commit hook. | +| `test_release.py` | 62 | 786 | Release policy and registry verification (#481): strict project-name and version parsing/comparison, version-bump/TestPyPI/recovery planning, exact confirmation and immutable-tag guards, first-parent version-introduction discovery, package-change recovery refusal, non-empty CHANGELOG extraction, one-wheel/one-sdist SHA-256 manifests, malformed registry-response handling, missing/filename/hash propagation retries, exact filename/hash verification, and CLI dispatch/GitHub-output wiring. An autouse fixture scrubs hook-exported `GIT_*` variables so the tmp-repo git calls (fixture helpers and `release.py`'s own) never resolve to the developer's repository when the suite runs inside a pre-commit hook. | | `test_check_doc_counts.py` | 89 | 987 | `check_doc_counts.py`'s pure per-document checks: KNOWN_ISSUES refactoring line counts (±10% tolerance band incl. the exact-boundary case, drift detection, empty-file citation, hyphenated paths, missing file/section/rows, the #419 empty-section sentinel + its cannot-mask-a-malformed-table dual), HISTORY version-row format (issue-link limit, ` — ` separator rejection, dateless-row and prose exemption, line-number reporting), the TESTING.md tests breakdown (parts summing to the collected total, a self-consistent-but-stale row, and the reworded-row error), and vera/README.md's Test Suite counts (all four checked independently — mutation-validated by dropping each citation in turn — plus the reworded-paragraph error, a thousands-separator case pinning that every one of the four counts is read comma-tolerantly, and the two section-anchoring cases — a reworded paragraph with decoy counts in a later section, and a renamed heading, both of which must fail loud rather than match across the section boundary). The reworded case is a test in its own right for both new checks: a pattern that matches nothing must be an error, or rewording the sentence silently switches the gate off. Also the release count (README's status line and HISTORY's total against each other and against `git tag`: the matching case, the one-ahead release cut that `release.yml` has not tagged yet, that +1 being the ONLY slack once the version is tagged, the two-behind drift that actually shipped, per-document reporting, and a tagless checkout standing the oracle down without standing down the cross-check), plus the tag reader itself against real repositories built in `tmp_path` — release tags read, `nightly`/`-rc1` not counted as releases, and both no-evidence answers (`None` rather than `[]`, since an empty list would read as zero releases and make every documented count wrong) for a tagless checkout and a directory that is not a repository at all. CONTRIBUTING.md's pre-commit hook count is checked the same way, reworded-sentence case included; and the CI-pipeline lint row against `ci.yml`'s lint job — a matching row, a step present in CI but absent from the row (the drift that shipped), a row entry CI no longer runs, the same set in a different order, a reworded row and a renamed job (both errors, not skips), that only the lint job is read rather than the whole workflow, and the shipped pair both clean and red with one entry dropped | -| `test_grammar_alignment.py` | 85 | 672 | `check_grammar_alignment.py` gate ([#683](https://github.com/aallan/vera/issues/683)): both extractors (Lark headers with the `?`/`!`/`_` markers stripped, template parameters and rule priorities tolerated, and `-> alias` names deliberately not collected; spec headers from ```ebnf fences only), the allowlist arithmetic in all three directions — unwaived drift, a spent entry both files now have, and an entry whose premise broke — one case per waiver proving the fact it rests on is actually checked, the name-deleted-from-both-files case that must not read as agreement, a mutation restoring the spec's old `assert_stmt` name, non-vacuous extraction, and the false positive the issue itself rested on: `qualified_call` and `module_call` are spec headers Lark expresses as aliases, and must never be reported as drift. Three pin the premise checks against ways they used to pass vacuously: an alias surviving only inside a `//` comment must not hold its waiver up, an alias that moved to another rule must fail the waiver naming `fn_call`, and a spent waiver whose premise also broke must yield one instruction rather than two opposite ones | -| `test_check_examples_run.py` | 77 | 1,280 | `check_examples_run.py`, the harness gate that RUNS the examples. Five separable parts, each in both directions. **The coverage rule** — the shipped tables cover the shipped corpus exactly, and an unclassified example, a stale `RUN_SPECS` or `SKIPS` key whose file is gone, a name in both tables, and a skip citing an undocumented property are each an error; the empty corpus is an error too, since a glob that stops matching would otherwise report success over zero programs. Plus the specs' own well-formedness: every named entry point is `public` in its example, every no-main example pins one (or `vera run` would fall back to an arbitrary first export), and no skip property is unused. **The runner** — a seeded `tmp_path` corpus proves it goes red on a program that type-checks and compiles but traps at run time, green on one that does not, and reports only the broken member of a mixed pair; a fixture whose first export is clean and whose named one traps proves `spec.fn` is actually honoured rather than ignored; a writer program proves each run gets a scratch working directory, so a gate run leaves nothing beside the examples. **The TESTING.md cross-check** — missing row, extra row, rename (reported naming both sides), wrong disposition and wrong skip property are errors, the parse stops at the next heading so a row-shaped line in a later section is not swept in, and both a reworded heading and a heading whose table has vanished fail loud rather than finding nothing to compare. **The output signal** -- the second half of the two-signal discipline `check_examples.py` established: the fallback note that `vera run` prints when it cannot use the named entry point is a failure even at exit 0, an absent `expect` sentinel is a failure even at exit 0, and a spec without one asserts nothing about output; end to end, a privatised `main` and a program that completed down a graceful arm each go red, and the same program passes once its own output is the sentinel, so the check reads the output rather than always failing. Which specs must carry a sentinel is **derived** from what each example declares — a resource effect in a function's effect row, or a call to a resource operation, both validated against the live effect registry so a renamed effect or op fails loudly — and asserted equal to the specs that have one, in both directions; the previous hard-coded triple could not see a fourth such example arriving without one. The runner's use of BOTH streams is pinned structurally -- `vera run` writes the note to stderr and nothing there on a clean exit, so no fixture can distinguish reading both streams from reading stdout alone, and a tripwire wired to the wrong stream is no tripwire. Also the hermetic-environment property: an ambient `VERA_DB_URL` or provider key is stripped so a gate run cannot be pointed at a real database or turned into a billed API call, a fixture spec puts its own URL back, and every neutralised name is checked to be one `vera/runtime/` actually reads | -| `test_check_corpus_differential.py` | 46 | 758 | `check_corpus_differential.py`, the burndown instrument that compiles the corpus at two revisions. The pure pieces only — the real two-revision run costs minutes and is not a test. **Classification**, all four verdicts: identical, WAT differs, and each one-sided compile failure as its own kind, since a compilability reversal reported as a text difference is the mis-description the instrument exists to avoid; failing at both revisions is not a mover and is counted separately, so a green run states how much of it was vacuous. **Enumeration** — recursive, keyed by repo-relative POSIX path, and an empty corpus is an error rather than a clean run over nothing. **The canary** — each side must import the compiler it was pointed at, so a side silently resolving to the venv's editable install cannot compare a revision against itself; an import failure and a foreign compiler are different messages. **Reporting** — every mover named with its reason, the exit code, the `--json` shape, and a program missing from one side reported rather than dropped. One test asserts the instrument is absent from `.pre-commit-config.yaml`, so the docstring's claim cannot rot | +| `test_grammar_alignment.py` | 89 | 706 | `check_grammar_alignment.py` gate ([#683](https://github.com/aallan/vera/issues/683)): both extractors (Lark headers with the `?`/`!`/`_` markers stripped, template parameters and rule priorities tolerated, and `-> alias` names deliberately not collected; spec headers from ```ebnf fences only), the allowlist arithmetic in all three directions — unwaived drift, a spent entry both files now have, and an entry whose premise broke — one case per waiver proving the fact it rests on is actually checked, the name-deleted-from-both-files case that must not read as agreement, a mutation restoring the spec's old `assert_stmt` name, non-vacuous extraction, and the false positive the issue itself rested on: `qualified_call` and `module_call` are spec headers Lark expresses as aliases, and must never be reported as drift. Three pin the premise checks against ways they used to pass vacuously: an alias surviving only inside a `//` comment must not hold its waiver up, an alias that moved to another rule must fail the waiver naming `fn_call`, and a spent waiver whose premise also broke must yield one instruction rather than two opposite ones | +| `test_check_examples_run.py` | 79 | 1,315 | `check_examples_run.py`, the harness gate that RUNS the examples. Five separable parts, each in both directions. **The coverage rule** — the shipped tables cover the shipped corpus exactly, and an unclassified example, a stale `RUN_SPECS` or `SKIPS` key whose file is gone, a name in both tables, and a skip citing an undocumented property are each an error; the empty corpus is an error too, since a glob that stops matching would otherwise report success over zero programs. Plus the specs' own well-formedness: every named entry point is `public` in its example, every no-main example pins one (or `vera run` would fall back to an arbitrary first export), and no skip property is unused. **The runner** — a seeded `tmp_path` corpus proves it goes red on a program that type-checks and compiles but traps at run time, green on one that does not, and reports only the broken member of a mixed pair; a fixture whose first export is clean and whose named one traps proves `spec.fn` is actually honoured rather than ignored; a writer program proves each run gets a scratch working directory, so a gate run leaves nothing beside the examples. **The TESTING.md cross-check** — missing row, extra row, rename (reported naming both sides), wrong disposition and wrong skip property are errors, the parse stops at the next heading so a row-shaped line in a later section is not swept in, and both a reworded heading and a heading whose table has vanished fail loud rather than finding nothing to compare. **The output signal** -- the second half of the two-signal discipline `check_examples.py` established: the fallback note that `vera run` prints when it cannot use the named entry point is a failure even at exit 0, an absent `expect` sentinel is a failure even at exit 0, and a spec without one asserts nothing about output; end to end, a privatised `main` and a program that completed down a graceful arm each go red, and the same program passes once its own output is the sentinel, so the check reads the output rather than always failing. Which specs must carry a sentinel is **derived** from what each example declares — a resource effect in a function's effect row, or a call to a resource operation, both validated against the live effect registry so a renamed effect or op fails loudly — and asserted equal to the specs that have one, in both directions; the previous hard-coded triple could not see a fourth such example arriving without one. The runner's use of BOTH streams is pinned structurally -- `vera run` writes the note to stderr and nothing there on a clean exit, so no fixture can distinguish reading both streams from reading stdout alone, and a tripwire wired to the wrong stream is no tripwire. Also the hermetic-environment property: an ambient `VERA_DB_URL` or provider key is stripped so a gate run cannot be pointed at a real database or turned into a billed API call, a fixture spec puts its own URL back, and every neutralised name is checked to be one `vera/runtime/` actually reads | +| `test_check_corpus_differential.py` | 50 | 861 | `check_corpus_differential.py`, the burndown instrument that compiles the corpus at two revisions. The pure pieces only — the real two-revision run costs minutes and is not a test. **Classification**, all four verdicts: identical, WAT differs, and each one-sided compile failure as its own kind, since a compilability reversal reported as a text difference is the mis-description the instrument exists to avoid; failing at both revisions is not a mover and is counted separately, so a green run states how much of it was vacuous. **Enumeration** — recursive, keyed by repo-relative POSIX path, and an empty corpus is an error rather than a clean run over nothing. **The canary** — each side must import the compiler it was pointed at, so a side silently resolving to the venv's editable install cannot compare a revision against itself; an import failure and a foreign compiler are different messages. **Reporting** — every mover named with its reason, the exit code, the `--json` shape, and a program missing from one side reported rather than dropped. One test asserts the instrument is absent from `.pre-commit-config.yaml`, so the docstring's claim cannot rot | | `test_check_editor_grammars.py` | 20 | 249 | `check_editor_grammars.py` gate ([#1156](https://github.com/aallan/vera/issues/1156)): the registry read (every effect in, every ability out, and the four names the grammars actually drifted on present so the set checked is non-vacuous), the word-boundary presence test across all three grammar formats (JSON, plist XML, Vim keyword list) including the prefix pair `Http`/`HttpServer` in both directions, the deliberate comment-mention false pass, a metacharacter pair that only passes when the name is matched literally (`A.B` present, `A0B` not), and the empty registry; the gate's primary path end to end — a listed grammar with an effect stripped out, and a listed README with one stripped out of its prose bullet, each red against an otherwise-clean mirrored tree; the completeness guard — a grammar discovered under `editors/` but absent from `GRAMMARS` fails that same tree, over three discovery routes (`.el` outside a syntax directory, a tree-sitter `.scm` query set, a `.tmLanguage.json` filed anywhere but `syntaxes/`); and the registry's provenance, run as a subprocess against a throwaway checkout whose `vera` package names an effect the grammars do not, which is the only way to see that the list comes from the tree being checked rather than from site-packages. The shipped grammars and READMEs are currently clean | | `test_check_explicit_encoding.py` | 54 | 254 | `check_explicit_encoding.py` gate (#645): flags text-mode `open()` / `read_text()` / `write_text()` **and** `subprocess.run/Popen/check_output(..., text=True)` captures missing an `encoding="utf-8"` literal (rejects non-literal / non-UTF-8 values), skips binary/bytes-mode calls, honours the `# encoding-exempt` opt-out, and asserts the shipped repo is clean | | `test_check_limitations_sync.py` | 6 | 108 | `check_limitations_sync.py` section extraction: table-rows-only issue harvesting, prose-link exemption, bounding at the next second-level heading, `None` for absent or sub-level headings so renamed sections fail loudly; plus the #852 fail-loud rule: an UNKNOWN issue state under `--check-states` (gh missing / auth failure / timeout) is an error, never a silent pass | diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 7f3afe7b..880d69b5 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -3265,7 +3265,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 11,940 tests, including a 244-program conformance suite +- 11,951 tests, including a 244-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/scripts/check_corpus_differential.py b/scripts/check_corpus_differential.py index 1a6d9847..d5278ac4 100644 --- a/scripts/check_corpus_differential.py +++ b/scripts/check_corpus_differential.py @@ -544,10 +544,30 @@ def base_checkout( encoding="utf-8", check=False, ) + status = subprocess.run( + ["git", "-C", str(dest), "status", "--porcelain"], + capture_output=True, + text=True, + encoding="utf-8", + check=False, + ) + # The tree must be CLEAN, not merely at the right commit. A + # reused checkout is persistent by design, so an edit made under + # it — a stray debug print, an abandoned bisect — survives to the + # next run and silently becomes the base compiler. `rev-parse` + # cannot see that, and neither can the canary: it proves which + # checkout was imported, and a modified one is still that + # checkout. A dirty base makes "0 movers" mean nothing and can + # invent movers out of the edit, which is the one failure this + # instrument must not have. The message below has always + # promised "a clean checkout"; this is what makes it true + # (#1330 review). if ( current.returncode == 0 and current.stdout.strip() == sha and (dest / "vera" / "__init__.py").is_file() + and status.returncode == 0 + and not status.stdout.strip() ): return dest, "" return None, ( diff --git a/scripts/check_examples_run.py b/scripts/check_examples_run.py index 15c1bc81..88534e27 100644 --- a/scripts/check_examples_run.py +++ b/scripts/check_examples_run.py @@ -571,6 +571,13 @@ def check_output(name: str, spec: RunSpec, output: str) -> str | None: back to an arbitrary export, and an external fixture that vanishes, where the example takes its graceful arm. """ + # A backstop, not the live path. `build_command` always passes + # `--fn`, so `vera run` refuses a missing or private export outright + # (non-zero, "not found in exports") and never reaches its + # first-export fallback — the end-to-end cell in the tests pins that + # refusal, and another pins that `--fn` is always passed, which is + # what keeps this branch unreachable. It stays as the tripwire for a + # `build_command` that stops passing it (#1330 review). if FALLBACK_NOTE in output: return ( f"{name}: `vera run` could not use the entry point " diff --git a/scripts/check_grammar_alignment.py b/scripts/check_grammar_alignment.py index f27e1144..4e04bdbd 100644 --- a/scripts/check_grammar_alignment.py +++ b/scripts/check_grammar_alignment.py @@ -566,10 +566,20 @@ def _literal_terminals(spec_lines: list[str]) -> tuple[dict[str, str], list[str] def _symbols(line: str, rules: set[str]) -> tuple[set[str], set[str]]: - """``(rule references, terminal references)`` in one production body line.""" + """``(rule references, terminal references)`` in one production body line. + + Quoted literals are blanked once, for BOTH halves. Only the terminal + half used to blank them, so a Lark literal spelling a lowercase word + — `"handle"`, `"where"` — was counted as a reference to a rule of + that name whenever such a rule existed. No literal collides with a + rule name today, which is exactly why it would have arrived as a + silent false report rather than as a failure anyone had asked for + (#1330 review). + """ + code = _QUOTED.sub(" ", line) return ( - {name for name in re.findall(r"\b[a-z][a-z0-9_]*\b", line)} & rules, - set(_TERMINAL_REF.findall(_QUOTED.sub(" ", line))), + {name for name in re.findall(r"\b[a-z][a-z0-9_]*\b", code)} & rules, + set(_TERMINAL_REF.findall(code)), ) diff --git a/scripts/release.py b/scripts/release.py index b4f42437..1a7e6884 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -193,6 +193,7 @@ def condense_notes( section: ChangelogSection, *, repo: str = REPOSITORY, + budget: int = RELEASE_BODY_BUDGET, limit: int = GITHUB_RELEASE_BODY_LIMIT, ) -> str: """Rewrite a release section as the headline index plus a CHANGELOG link. @@ -204,9 +205,15 @@ def condense_notes( """ anchor = changelog_anchor(section.version, section.date) dated = f"[{section.version}]" + (f" - {section.date}" if section.date else "") + # Worded against the threshold that actually fired. Condensing starts + # at the budget, not at the hard limit, so a section in the band + # between them was published saying it was "past GitHub's + # 125,000-character limit" while being comfortably under it — a + # falsehood shipped verbatim in the release body (#1330 review). preamble = ( f"The full release notes for this version are {len(section.notes):,} " - f"characters — past GitHub's {limit:,}-character release-body limit — so " + f"characters, past the {budget:,}-character budget this project " + f"publishes verbatim — GitHub's own limit is {limit:,} characters — so " "this body carries the headline index and the canonical notes live in the " f"CHANGELOG at the tag: **[CHANGELOG.md § {dated}]" f"(https://github.com/{repo}/blob/v{section.version}/CHANGELOG.md{anchor})**" @@ -244,7 +251,7 @@ def release_body( """ if len(section.notes) <= budget: return section.notes - condensed = condense_notes(section, repo=repo, limit=limit) + condensed = condense_notes(section, repo=repo, budget=budget, limit=limit) if len(condensed) <= limit: return condensed notice = ( diff --git a/tests/test_check_corpus_differential.py b/tests/test_check_corpus_differential.py index 8a866208..7787c3bc 100644 --- a/tests/test_check_corpus_differential.py +++ b/tests/test_check_corpus_differential.py @@ -32,6 +32,7 @@ import importlib.util import io import json +import os import subprocess import threading from pathlib import Path, PureWindowsPath @@ -756,3 +757,105 @@ def test_the_byte_is_undecodable_in_the_codec_the_script_names(self) -> None: with pytest.raises(UnicodeDecodeError): b"\x97".decode("utf-8") assert b"\x97".decode("cp1252") == "\u2014" + + +def _seed_repo(root: Path) -> str: + """A one-commit git repo shaped enough for `base_checkout`.""" + root.mkdir(parents=True, exist_ok=True) + run = lambda *a: subprocess.run( # noqa: E731 + ["git", "-C", str(root), *a], check=True, + capture_output=True, text=True, encoding="utf-8", + ) + run("init", "-b", "main") + run("config", "user.email", "differential-test@example.invalid") + run("config", "user.name", "Differential Test") + (root / "vera").mkdir(exist_ok=True) + (root / "vera" / "__init__.py").write_text("__version__ = '0'\n", encoding="utf-8") + run("add", ".") + run("commit", "-m", "seed") + return subprocess.run( + ["git", "-C", str(root), "rev-parse", "HEAD"], + check=True, capture_output=True, text=True, encoding="utf-8", + ).stdout.strip() + + +class TestBaseCheckoutReuse: + """The persistent base checkout is reused, so it must be CLEAN (#1330). + + `rev-parse HEAD` proves the commit and nothing about the tree. A + reused checkout survives between runs by design, so an edit made + under it — a stray print, an abandoned bisect — becomes the base + compiler on the next run. The canary cannot object: it proves which + checkout was imported, and a modified one is still that checkout. A + dirty base makes "0 movers" mean nothing and can manufacture movers + out of the edit. + """ + + @pytest.fixture(autouse=True) + def _hermetic_git_env(self, monkeypatch: Any) -> None: + # Pre-commit exports GIT_DIR/GIT_INDEX_FILE into the hook's + # environment, which would override each call's `-C` and drive + # the developer's own repository instead of the tmp one. + for name in [k for k in os.environ if k.startswith("GIT_")]: + monkeypatch.delenv(name, raising=False) + + def test_a_clean_reused_checkout_is_accepted(self, tmp_path: Path) -> None: + repo = tmp_path / "repo" + sha = _seed_repo(repo) + work = tmp_path / "work" + first, error = _MOD.base_checkout(repo, sha, work) + assert error == "" and first is not None + + again, error = _MOD.base_checkout(repo, sha, work) + assert error == "", "the clean checkout must be reused, not refused" + assert again == first + + def test_a_dirty_reused_checkout_is_refused(self, tmp_path: Path) -> None: + repo = tmp_path / "repo" + sha = _seed_repo(repo) + work = tmp_path / "work" + dest, error = _MOD.base_checkout(repo, sha, work) + assert error == "" and dest is not None + + # The edit a reused checkout can carry between runs. HEAD is + # untouched, so the commit check still passes. + (dest / "vera" / "__init__.py").write_text( + "__version__ = '0'\nSTRAY = True\n", encoding="utf-8" + ) + + again, error = _MOD.base_checkout(repo, sha, work) + assert again is None, "a modified base compiled the differential" + assert "clean checkout" in error + assert "--work-dir" in error, "the refusal must keep its recreate guidance" + + def test_an_untracked_file_also_makes_it_dirty(self, tmp_path: Path) -> None: + """An added file is as much a different compiler as an edited one.""" + repo = tmp_path / "repo" + sha = _seed_repo(repo) + work = tmp_path / "work" + dest, error = _MOD.base_checkout(repo, sha, work) + assert error == "" and dest is not None + + (dest / "vera" / "sitecustomize_probe.py").write_text("x = 1\n", encoding="utf-8") + + again, error = _MOD.base_checkout(repo, sha, work) + assert again is None and "clean checkout" in error + + def test_the_commit_check_alone_would_not_have_caught_it( + self, tmp_path: Path + ) -> None: + """Non-vacuity: the dirty tree must still be at the right commit, + or this class is testing the pre-existing `rev-parse` check.""" + repo = tmp_path / "repo" + sha = _seed_repo(repo) + work = tmp_path / "work" + dest, _ = _MOD.base_checkout(repo, sha, work) + assert dest is not None + (dest / "vera" / "__init__.py").write_text("STRAY = True\n", encoding="utf-8") + + head = subprocess.run( + ["git", "-C", str(dest), "rev-parse", "HEAD"], + check=True, capture_output=True, text=True, encoding="utf-8", + ).stdout.strip() + assert head == sha + assert (dest / "vera" / "__init__.py").is_file() diff --git a/tests/test_check_examples_run.py b/tests/test_check_examples_run.py index 5980deb7..c9e08d89 100644 --- a/tests/test_check_examples_run.py +++ b/tests/test_check_examples_run.py @@ -27,6 +27,8 @@ import importlib.util import re +import subprocess +import sys from pathlib import Path from typing import Any @@ -762,11 +764,44 @@ class TestOutputSignals: def test_clean_output_passes(self) -> None: assert _MOD.check_output("a", _MOD.RunSpec(), "all good") is None + def test_every_command_names_its_entry_point(self) -> None: + """What makes the fallback unreachable, pinned. + + `build_command` always passes `--fn`, so `vera run` refuses a + missing or private export instead of falling back to the first + one. The backstop below guards a path the command line cannot + take *while this holds* — so this is the cell that has to hold + (#1330 review). + """ + for name, spec in _MOD.RUN_SPECS.items(): + assert spec.fn, f"{name} names no entry point" + cmd = _MOD.build_command("python", Path(f"{name}.vera"), spec) + assert "--fn" in cmd, name + assert cmd[cmd.index("--fn") + 1] == spec.fn, name + + def test_an_unresolvable_entry_point_fails_loudly(self, tmp_path: Path) -> None: + """The real path, end to end: the failure a privatised or renamed + `main` actually produces is a refusal, not a silent fallback.""" + d = _corpus(tmp_path, {"prog": _CLEAN_SRC}) + spec = _MOD.RunSpec(fn="not_an_export") + result = subprocess.run( + _MOD.build_command(sys.executable, d / "prog.vera", spec), + capture_output=True, text=True, encoding="utf-8", + cwd=str(tmp_path), check=False, + ) + assert result.returncode != 0, "an unresolvable entry point exited 0" + combined = result.stdout + result.stderr + assert "not found in exports" in combined + assert _MOD.FALLBACK_NOTE not in combined, ( + "vera run fell back instead of refusing, so the backstop below " + "is load-bearing after all" + ) + def test_fallback_note_is_a_failure_at_exit_zero(self) -> None: - """`vera run` prints this note when it cannot use the named entry - point and picks the first export instead. Every spec names its - entry point, so seeing the note means the resolution did not - happen — a run of some other function reported as a pass.""" + """The backstop, kept for a `build_command` that stops passing + `--fn`. Unreachable through the live command path — the two + cells above are what establish that — so it is asserted directly + on `check_output` rather than end to end.""" msg = _MOD.check_output( "a", _MOD.RunSpec(), "Note: no 'main' declared — running public function 'other'.\n7", diff --git a/tests/test_grammar_alignment.py b/tests/test_grammar_alignment.py index 41095fe7..084c3e9d 100644 --- a/tests/test_grammar_alignment.py +++ b/tests/test_grammar_alignment.py @@ -670,3 +670,37 @@ def test_a_slash_inside_a_character_class_is_not_the_delimiter( def test_a_comment_after_a_class_bearing_regex_is_still_removed(self) -> None: assert _MOD.strip_comment(r"T: /[^/*]/ // note") == r"T: /[^/*]/ " + + +class TestQuotedLiteralsInBodies: + """A quoted literal is not a rule reference (#1330 review). + + Only the terminal half of `_symbols` blanked quoted literals, so a + Lark literal spelling a lowercase word counted as a reference to a + rule of that name. Latent: no literal in the grammar collides with + a rule name today, which is why it would have surfaced as a silent + false report on some later edit rather than as a failure now — and + why it needs a unit cell, the shipped files being unable to show it. + """ + + def test_a_literal_is_not_read_as_a_rule_reference(self) -> None: + rules, terminals = _MOD._symbols('foo: "handle" bar', {"handle", "bar", "foo"}) + assert "handle" not in rules + assert rules == {"foo", "bar"}, "positive control: real references survive" + assert terminals == set() + + def test_a_literal_is_not_read_as_a_terminal_reference_either(self) -> None: + """The half that already blanked them, pinned against a + regression in the other direction.""" + _rules, terminals = _MOD._symbols('foo: "SEMICOLON" BAR', {"foo"}) + assert terminals == {"BAR"} + + def test_both_halves_read_the_same_blanked_text(self) -> None: + line = 'stmt: "where" WHERE where_block' + rules, terminals = _MOD._symbols(line, {"stmt", "where", "where_block"}) + assert rules == {"stmt", "where_block"}, "the `where` literal leaked" + assert terminals == {"WHERE"} + + def test_an_escaped_quote_inside_a_literal_does_not_end_it(self) -> None: + rules, _t = _MOD._symbols(r'str_lit: "\"" body "\""', {"str_lit", "body"}) + assert rules == {"str_lit", "body"} diff --git a/tests/test_release.py b/tests/test_release.py index 451d2a2d..27e0294d 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -198,7 +198,36 @@ def test_the_condensed_body_states_the_measured_length_and_the_limit(self) -> No ) body = release.release_body(section, repo="aallan/vera") assert f"{len(section.notes):,} characters" in body - assert f"{release.GITHUB_RELEASE_BODY_LIMIT:,}-character" in body + assert f"{release.RELEASE_BODY_BUDGET:,}-character budget" in body + assert f"{release.GITHUB_RELEASE_BODY_LIMIT:,} characters" in body + + def test_a_section_between_the_budget_and_the_limit_says_so_truthfully( + self, + ) -> None: + """The band the old wording lied in. + + Condensing starts at the budget, not at GitHub's limit, so a + section of 120,001-125,000 characters is condensed while being + under the limit. The preamble used to say it was "past GitHub's + 125,000-character release-body limit" — a falsehood published + verbatim in the release body (#1330 review). + """ + section = _section( + "### Fixed\n\n" + + "\n".join(f"- **Lead {n}.** {'z' * 2400}" for n in range(50)) + ) + size = len(section.notes) + assert release.RELEASE_BODY_BUDGET < size <= release.GITHUB_RELEASE_BODY_LIMIT + + body = release.release_body(section, repo="aallan/vera") + assert body != section.notes, "the band must still condense" + # It is past the budget, and it is NOT past the limit. The + # preamble must not claim otherwise. + assert f"past the {release.RELEASE_BODY_BUDGET:,}-character budget" in body + preamble = body.splitlines()[0] + assert "past GitHub" not in preamble + assert f"past the {release.GITHUB_RELEASE_BODY_LIMIT:,}" not in preamble + assert f"{size:,} characters" in preamble def test_the_index_reproduces_the_v0110_recovery_shape(self) -> None: """The lead-in carries the bullet's LAST issue/PR link, wrapped. diff --git a/vera/README.md b/vera/README.md index 33c79773..544c9d55 100644 --- a/vera/README.md +++ b/vera/README.md @@ -753,7 +753,7 @@ The `ERROR_CODES` dict in `errors.py` maps every code to a short description (16 ## Test Suite -Testing spans a **pytest suite** of 11,940 tests across 175 files — compiler-internals unit tests plus a **conformance suite** (244 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. +Testing spans a **pytest suite** of 11,951 tests across 175 files — compiler-internals unit tests plus a **conformance suite** (244 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. See **[TESTING.md](../TESTING.md)** for the comprehensive testing reference -- test file table, conformance suite details, compiler code coverage, language feature coverage, helper conventions, validation scripts, CI pipeline, and guidelines for adding tests. From 502ee7cd10686d8e2417e0b741ad372cfd0ea935 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Sat, 15 Aug 2026 05:37:52 +0100 Subject: [PATCH 64/69] Take the review's cheap reals, refute the rest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eleven fixes from the release review, each verified against the code before being taken; two refutations; one real finding deliberately not fixed here. The unreported check in the corpus differential was exercised in one direction only. It is a symmetric difference, so both directions are one expression — but an implementation that iterated either map alone passed every cell in the class while under-reporting the corpus. Both directions and the both-sides case are pinned now, and each one-sided iteration kills a cell. The clash-report loop iterates in import order while the docstring three lines above promises sorted name order. The order is deterministic — measured stable across three hash seeds, and the producer documents import order as a deliberate choice — so the review's nondeterminism diagnosis is wrong, but the contract was false either way. Sorting by name makes it true and leaves each name's supplier list in the import order that lets the report name the import completing the clash. The rest are small and local: the `_handle_exn_always_throws` consumer name, wrong in two docstrings rather than the one reported; `Mapping` from `collections.abc`; token-anchored WAT constant assertions, where `i64.const 5` also matched `i64.const 50`; a bounded wait on the determinism suite's checker subprocess, an unbounded one reporting a non-terminating resolution loop as a hung suite rather than as the finding it is; the release-workflow gate matching the exact invocation instead of a 400-character proximity window; two cells that counted errors without attributing them; a conformance failure that surfaces the CLI streams when no JSON envelope arrives; and the complete `fn_call` alias set rather than one name of five. Refuted: the fail-closed `type_expr` raise is unreachable, not over-broad — an unrefined `throw` in a function declaring `effects(>)` with no enclosing handle is check-green, compiles with no diagnostic and runs, so the declared-effect-row producer threads the field. And an ambiguous data-type name whose constructors are unambiguous produces a clean E156 at the import with no cascade, so the constructor injection the review describes is unreachable by any accepted program. Not fixed here: a user `data Array` is check-green and compiles to a module with no exports, because the built-in container widths are consulted before the declared-ADT lookup. Real, measured, and pre-existing — v0.1.11 has the same order, and #1309 moved only the alias branch above these. Only `Array` misbehaves; `Map`, `Set` and `Decimal` already answer the ADT pointer's own width. The fix needs at least three derivations agreeing and two are not enough, which is not a change to land in a release commit on partial evidence. Co-Authored-By: Claude --- FAQ.md | 2 +- README.md | 2 +- ROADMAP.md | 2 +- TESTING.md | 16 ++++----- docs/llms-full.txt | 2 +- tests/test_ambiguous_import_refusal_1304.py | 5 +++ tests/test_check_corpus_differential.py | 34 +++++++++++++++++++ tests/test_check_doc_counts.py | 17 +++++++++- ..._closure_boundary_widths_1255_1256_1269.py | 8 +++-- tests/test_conformance.py | 12 ++++++- tests/test_grammar_alignment.py | 9 ++++- tests/test_release.py | 20 ++++++++--- vera/README.md | 2 +- vera/checker/modules.py | 10 +++++- vera/prelude.py | 2 +- vera/slots.py | 2 +- vera/wasm/context.py | 2 +- 17 files changed, 121 insertions(+), 26 deletions(-) diff --git a/FAQ.md b/FAQ.md index 963afe57..728cfd63 100644 --- a/FAQ.md +++ b/FAQ.md @@ -279,7 +279,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 11,951 tests, including a 244-program conformance suite +- 11,953 tests, including a 244-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/README.md b/README.md index 205a0123..212ef104 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ cp /path/to/vera/SKILL.md ~/.claude/skills/vera-language/SKILL.md ## Project status -Vera is in **active development** at v0.1.12: 2,000+ commits, 210 releases, 11,951 tests, 95% Python code coverage, 244 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. +Vera is in **active development** at v0.1.12: 2,000+ commits, 210 releases, 11,953 tests, 95% Python code coverage, 244 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. The reference compiler — parser, AST, type checker, contract verifier (Z3), WASM code generator, module system, browser runtime, and runtime contract insertion — is working. The language specification is in draft across [14 chapters](spec/). diff --git a/ROADMAP.md b/ROADMAP.md index 257c0938..3109512b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,7 +12,7 @@ Ordering derives from the design principles ([DESIGN.md](DESIGN.md)): verificati ## Where we are -11,951 tests, 244 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. +11,953 tests, 244 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. ## The v0.1.13 burndown diff --git a/TESTING.md b/TESTING.md index 16d95536..854c16f0 100644 --- a/TESTING.md +++ b/TESTING.md @@ -6,7 +6,7 @@ This is the single source of truth for Vera's testing infrastructure, coverage d | Metric | Value | |--------|-------| -| **Tests** | 11,951 across 175 files (~155,000 lines of test code; 11,752 passed + 26 stress-deselected, 173 skipped) | +| **Tests** | 11,953 across 175 files (~155,000 lines of test code; 11,754 passed + 26 stress-deselected, 173 skipped) | | **Compiler code coverage** | 95% Python, 87% JavaScript (CI minimum: 80%) | | **Conformance programs** | 244 programs across 9 spec chapters, validating every language feature | | **Example programs** | 42, all validated through `vera check` + `vera verify` | @@ -78,7 +78,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_state_exn_registration.py` | 30 | 1296 | #1210 — State/Exn host-import registration covers the whole handler, not just its body. Four shapes, one per sub-expression position the walk used to miss (a nested handler in a clause body, in the state-init expression, in a clause's `with` update, and an `Exn` handler in a clause body), each check-green and verify-clean and therefore required to COMPILE — pre-fix every one died at whole-module WAT compilation with `unknown func` / `unknown tag`; plus the `i32_pair` cell (`handle[State]` in a `pure` function) that the walk skipped in silence, now the same loud E607 the declared-effect gate emits. The **registration-completeness differential** is the cross-component invariant itself: over every `examples/` + `tests/conformance/` program that compiles, every `state_*` / `exn_*` symbol the emitted WAT REFERENCES must have a matching import or tag DECLARATION — a desync between the registration pass and the lowering pass is invisible to a unit test on either. Round two adds the Exn twin of the silent skip (`handle[Exn]` in a `pure` function — the walk called the shared tag registration and discarded the verdict, so it compiled where the declared-row spelling was a clean E612) and the four CONTRACT positions, which are lowered code: a handler in a `requires`, an `ensures`, an `assert`, or a `decreases` measure. The differential gained a **validation leg** — every HANDLER-BEARING module is handed to `wasmtime.Module` through the exceptions-enabled engine `execute()` uses, because a symbol declared at the WRONG TYPE passes the name comparison while being invalid WASM, and 10 of the 30 handler-bearing modules fail to load with `wasm_exceptions` off — a supported wasmtime configuration, though the current runner defaults it on. The conformance suite's deliberate negatives are filtered out of the sweep: they never reach codegen through `vera check`. Carries floors on programs swept, modules validated, summed symbol references and globally distinct symbols, plus three can-go-red tests: the State and Exn extractions each stripped of their declaration lines, and a planted retyped import that only the validation leg catches. Round five adds the three positions no corpus program contained — a destructuring `let`'s value (which also disarmed the E612 gate), a module call's ARGUMENTS, and a signature refinement predicate reached through the alias table — and a cross-module shape test for the module-call leg. Round seven adds the boundary-guard routes that enumeration missed (a tuple parameter's components, a tuple return's, and a closure's refined formal and return) and the co-extensiveness half those shapes cannot show: a refined tuple behind a CLOSURE formal must declare nothing, since the closure path emits no component guards, and the nested-refinement (E618) and erased-base bails must stay silent registrars too. Plus the cycle guard on the closure signature leg, asserted in both directions — the walk terminates, and with the guard neutered the same walk blows the recursion limit | | `test_closure_lift_boundaries_1234_1235_1245.py` | 18 | 757 | Closure lifting at refinement boundaries — three burndown defects of one seam. **#1245**: `_lift_pending_closures` ran BEFORE `_compile_postconditions`, so a closure created while lowering a refined-RETURN guard, a tuple return's component guards, or an `ensures(...)` predicate was registered and never lifted — the table stayed empty, its `call_indirect` was orphaned, and the #1185 propagation dropped the function and every caller: a check-green, verify-clean program compiling to ZERO exports. The param-position twin (lowered before the lift, so it always worked) is carried as the control that makes it an ORDERING defect, the `ensures`-clause twin shows the same bug with no refinement in sight, and a violating return asserts the lifted guard ENFORCES rather than merely existing. **#1234**: the lift worklist fed itself — a refinement whose predicate holds a closure refined by a type whose chain leads back to it (`type SelfRef = { @Int \| ... fn(@SelfRef -> @Int) ... }`, and equally a mutual `A -> B -> A` or a three-type cycle) had each lift's own boundary guard queue an `AnonFn` for ever, and `vera compile` never returned. All three cycle lengths are asserted on a daemon thread with a wall-clock budget, so a regression fails fast instead of hanging the suite, and each on the [E602] naming the closure it refused (a guard that never fired cannot produce it). Two controls carry the other half — the guard is keyed on the lift CHAIN, not on everything already lifted, so `fn f(@R, @R -> @Int)` and a diamond, which each legitimately lift one predicate's closure twice, must still run; mutation-measured, they are the only two tests a seen-set spelling reddens. **#1235**: a `Tuple` formal crossing into a closure was unguarded where the named path traps — both spellings of the same boundary are run against each other, violating and passing | | `test_byte_literal_joins_1212.py` | 25 | 759 | #1212 — a `@Byte` literal inside a value-position join lowers at the i32 Byte width. `@Byte` is i32 (spec §11) while an int literal defaults to `i64.const`, and the #865 / #1092 coercions each tested for a TOP-LEVEL `IntLit` — which the checker's bidirectional coercion is equally happy to type inside an `if` or `match` branch. Ten write boundaries are parametrized with the literal in a branch (`let`, handler state-init, clause-dispatched `put`, bare `put`, get-clause `resume` — verbatim the form the E602 clause-lowerability skip message recommends — a clause `with` update, a `@Byte` call argument, a generic constructor field at `Box`, a lifted closure's own RETURN whose named twin had been coerced since #865 while the closure path had no such step, and a HETEROGENEOUS join at a `@Byte` return, where the arm the result-type decider reads is already i32 and a sibling is a bare literal — arm ORDER decided which way that one failed, so both orders and both paths are pinned). The module docstring states what that list is and is not: measured coverage, since the checker's single Byte coercion makes the true enumeration "every position propagating a Byte expectation", which nothing enumerates in one place, each a check-green program that failed WASM validation with `type mismatch: expected i32, found i64` before the fix. Every case carries a VALUE oracle (200, distinguishable from every other constant in its fixture) rather than merely asserting the module runs, and a separate test drives the OTHER branch so a fix that marked only the arm the result type is read off would still fail. The controls are the load-bearing half: a plain `@Int` join must stay i64 — pinned on 5,000,000,000, which an i32 store cannot represent, so a spreading mark is a wrong VALUE and not just a validation failure — a Byte join with no literal arm must be untouched, and a Byte-RETURNING literal join must keep its own #865 return coercion. The constructor-field case runs through the real pipeline (checker artifacts threaded), because the #1092 width keys on the checker-recorded target type | -| `test_closure_boundary_widths_1255_1256_1269.py` | 52 | 969 | Widths and pointer-ness at a closure or effect boundary — three burndown defects of one seam, each a boundary answering "what is this declared type" from something other than that type. **#1255**: GC pointer-ness was read off the SYNTACTIC head, so `type SmallByte = { @Byte \| ... }` was rooted on the shadow stack at the closure parameter, return and capture and at the two named-function twins. The oracle is a DIFFERENTIAL against the `@Byte` spelling of the same program rather than an absolute push count — these bodies must allocate or no prologue is emitted at all, so they legitimately root their own intermediates — with the base spelling's own count PINNED beside it, because equality alone also holds when both spellings root the scalar, which is the pre-fix state and what a mutation deleting the exclusion outright would produce. A genuine pointer at each of the four boundaries is the control (rooting nothing anywhere satisfies the differential too), and every shape runs under `VERA_EAGER_GC=1` — a collection at each `$alloc`, where removing a load-bearing push reads back as a wrong value rather than as a passing test. The heap-layout invariant the defect was inert behind is executable here: a module with no string pool at all — the exposure the issue named — still starts its heap above the inline scalar range, and shrinking the two constants that create that margin fires the build guard. **#1256**: the `apply_fn` `call_indirect` signature took each parameter's width from the ARGUMENT, so a `@Byte` formal fed a literal registered two incompatible `$closure_sig` types and trapped; asserted by run AND by the emitted signature list, since a value oracle alone would also pass if both sides converged on the wrong shared width. The join spelling, the refined formal, the function-type-alias arm of the formal recovery, a directly-called named twin and an i64 control (pinned above 2^32, which an i32 parameter cannot carry) surround it. **#1269**: `throw`'s payload was not a `@Byte` write boundary, so `throw(5)` into `Exn<{ @Byte \| @Byte.0 < 10 }>` put an `i64.const` under an i32 tag and failed WASM validation at load. Both halves of the width agreement are pinned — a fix that widened the TAG would also run, and would put a Byte cell at eight bytes everywhere else — across the bare, aliased, refined, branch-literal, qualified-`Exn.throw` and thrown-inside-the-handled-body spellings, the last two reaching registration paths the others do not | +| `test_closure_boundary_widths_1255_1256_1269.py` | 52 | 973 | Widths and pointer-ness at a closure or effect boundary — three burndown defects of one seam, each a boundary answering "what is this declared type" from something other than that type. **#1255**: GC pointer-ness was read off the SYNTACTIC head, so `type SmallByte = { @Byte \| ... }` was rooted on the shadow stack at the closure parameter, return and capture and at the two named-function twins. The oracle is a DIFFERENTIAL against the `@Byte` spelling of the same program rather than an absolute push count — these bodies must allocate or no prologue is emitted at all, so they legitimately root their own intermediates — with the base spelling's own count PINNED beside it, because equality alone also holds when both spellings root the scalar, which is the pre-fix state and what a mutation deleting the exclusion outright would produce. A genuine pointer at each of the four boundaries is the control (rooting nothing anywhere satisfies the differential too), and every shape runs under `VERA_EAGER_GC=1` — a collection at each `$alloc`, where removing a load-bearing push reads back as a wrong value rather than as a passing test. The heap-layout invariant the defect was inert behind is executable here: a module with no string pool at all — the exposure the issue named — still starts its heap above the inline scalar range, and shrinking the two constants that create that margin fires the build guard. **#1256**: the `apply_fn` `call_indirect` signature took each parameter's width from the ARGUMENT, so a `@Byte` formal fed a literal registered two incompatible `$closure_sig` types and trapped; asserted by run AND by the emitted signature list, since a value oracle alone would also pass if both sides converged on the wrong shared width. The join spelling, the refined formal, the function-type-alias arm of the formal recovery, a directly-called named twin and an i64 control (pinned above 2^32, which an i32 parameter cannot carry) surround it. **#1269**: `throw`'s payload was not a `@Byte` write boundary, so `throw(5)` into `Exn<{ @Byte \| @Byte.0 < 10 }>` put an `i64.const` under an i32 tag and failed WASM validation at load. Both halves of the width agreement are pinned — a fix that widened the TAG would also run, and would put a Byte cell at eight bytes everywhere else — across the bare, aliased, refined, branch-literal, qualified-`Exn.throw` and thrown-inside-the-handled-body spellings, the last two reaching registration paths the others do not | | `test_nested_handler_clause_ops.py` | 27 | 971 | #1211 — a handler clause body's bare `get`/`put` belongs to the handler's DECLARATION scope, not to the body it refines. Eight nested shapes, each asserted on all three components (checker accepts, verifier discharges clean, compiled program returns the checker-derived value): `put` in a put clause and in a get clause, a bare `get` in a `with` state-update expression, depth-3 nesting proving the IMMEDIATELY enclosing handler wins, the qualified `State.put` spelling, a nested handle expression inside a clause body (its registries must be restored to the declaration's, not the intervening handler's), and the two op-result-type mirrors — a bare `get(())` in match-scrutinee (`_effect_op_result_wt`) and array-element (`_effect_op_result_vera`) position, both of which emitted invalid WASM for a check-green program before the alignment. Every oracle is derived from the checker's story, never from what codegen emits, and a meta-test asserts each shape still SEPARATES enclosing-cell from inner-cell routing (the pre-fix value is recorded per case) so none can go vacuous. Round two adds the two dispositions of an EMPTY enclosing handler stack — the declared effect row (the only route that reads the restored `_effect_ops`, which every handler-enclosed case bypasses) and the outermost handler in a `pure` function (E122 at check) — the enclosing handler's own clause running on the outward-routed op (a transforming `with` one level out: 300100, where the intrinsic reading gives 300050), `IO.print` inside a clause body, the #1233 same-family refusals (nested handler, `with` expression, declared row) with their different-family control, and the outward-re-entry depth cap (below it, at it with a WAT-size bound, and past it as a loud E602) | | `test_handler_op_ownership_1284.py` | 15 | 419 | #1284 — whose declaration a bare `get`/`put` call site denotes. The checker resolves user-fn-first (pinned directly: an over-applied `get` under a `handle[State]` reports the USER signature's arity), and codegen used to answer that question twice more and differently — the declared-effect row withheld the op when a function owned the name, the handler expression overwrote unconditionally. Four shapes from check-green source, each asserted on the CHECKER's value and on the dispatch target in the emitted WAT: a handled body returning the cell instead of the function's answer (silently 5 for 4), a `@Bool`-returning user `get` whose module WASM validation rejected, same-family nesting refused outright with a spurious `[E602]` naming a State operation the source never contained, and different-family nesting emitting the enclosing cell's getter at the wrong width. A parametrized differential runs all five shapes (the four plus a user `put`) as one table with each case's pre-fix behaviour recorded, so a case that stops distinguishing the two answers is visible rather than vacuous; the controls — an unshadowed handler and an unshadowed declared row, both of which must still reach the intrinsics — are what a fix that simply stopped installing the ops would fail, and `new(State)` under a shadowed op name pins that the #1285 family registry composes with this | | `test_new_state_family_1285.py` | 9 | 323 | #1285 — which cell `new(State)` reads under a multi-`State` effect row. `old()` has been family-keyed since #1205/#1209 while `new()` read the name-keyed op registry, so the two sides of one `ensures` clause read different cells: `effects(, State>)` with `ensures(new(State) == …)` was check-green and verify-green, put `state_get_Int`'s i64 into the Bool comparison's `i32.eq`, and died at load. Three multi-row cases — the width-mismatched shape that could not load, an `Int`/`Nat` pair that loaded and answered about the wrong cell, and `old()` beside `new()` of one family, whose unchanged-cell claim the runtime refuted on a contract the verifier had discharged — plus the single-`State` and alias-spelled controls the whole existing corpus exercises. Each cell is seeded from a caller's handler at a value the other cell is not holding, so a wrong-cell read cannot coincide with the right answer, and a deliberately false postcondition asserts the Tier 3 runtime check really traps, without which every "the program runs" assertion here would prove nothing | @@ -88,7 +88,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_import_visibility_entry_point_1244.py` | 6 | 269 | #1244 — `vera check` reports the same diagnostics whether it was given a module or a file that imports it. Registration alone says what a module DECLARES; the importer never checked its bodies, so a name a module never imported was rejected standalone (E200, §8.5.1) and accepted in silence through an importer. Written as EQUALITY between the two entry points rather than as "the importer warns", because the property is agreement — a future change making the standalone verdict lenient would satisfy a one-sided assertion and must fail here on the standalone leg. Six cases: the leaked unimported name, the honest control that imports what it uses (green before and after, so the new body check is the visibility rule rather than a blanket rejection of cross-module programs), the issue's type-error-through-importer shape (an `@Int` call bound to a `@Bool` slot: check-clean, Tier-1, failing at compile), a diamond proving each module is reported ONCE (the body check is memoised by path across nested checkers), and both entry points into an import cycle proving the memo terminates it | | `test_clone_body_declaring_module_1241_1243.py` | 5 | 344 | #1241 + #1243 — an imported generic's clone body resolves its bare calls in the DECLARING module, on both sides. The verifier's lexical lookup fell through to the importer's registry (`_declaring_module_scope` swapped the naming env, source and file but not the function registry) and codegen's clone-emission door was the one door that did not thread the module's intra-rename map, so `glib`'s `gen` called the importer's `need`. The two halves are one routing rule, and the tests are written so neither passes alone: each case asserts the `vera verify` verdict AND the `vera run` value together, so the verifier half alone (which makes verify clean while the compiled program still traps on the postcondition it just proved — the measured false Tier-1) fails the same test the codegen half alone (right value, verify still refusing) fails. Every expected value comes from the module verified and run STANDALONE, never from what the importer produces. Shapes: a private direct callee, a two-hop private chain, the type-discriminating pair (`@Int` vs `@Bool` — check-green source that emitted invalid WASM), and the unshadowed-callee control that was correct before and after, which is what pins the defect to the SHADOWED name rather than to cross-module calls in general | | `test_module_generic_namespace_1274.py` | 24 | 1,015 | #1274 — a module generic that does not own the importer's bare name is reached under `mod$$name`. Pre-fix only PRIVATE module generics were routed that way (#1000), so a PUBLIC one collided with the importer's same-named generic in the clone-name space: both files' `gen2` mangled to one `gen2$Bool`, one overwrote the other, and the module's own body ran the importer's — a **false Tier-1** (`check`/`verify` clean, the module's proved `ensures` violated at run: 999 where the declaring module answers 111). The full visibility matrix (module generic × importer generic), the import-filter dimension (out-of-filter, in-filter, wildcard), the unshadowed-out-of-filter cell that assembled to `unknown func $gen2`, and a type-discriminating shape whose two clones have different WAT result types. Each cell asserts the verify VERDICT and the runtime VALUE together in one test — a clean verify beside a violated postcondition IS the bug, so splitting them across sibling tests would let it hide — against the standalone library as oracle, and re-checks that the importer's own generic still answers its own value. The per-module both-sides differential lives in `test_monomorphize_differential.py`. Two further families joined after the adversarial round: the module→module **hop** — a module's bare call to a DIFFERENT module's qualified-only generic, which the per-module classification never rerouted, in both a LOUD spelling (a contract pins the answer, so a captured call traps) and a SILENT one (every contract admits both answers, so only the value distinguishes them) plus the two-hop shape where the entry never imports the declaring module at all; and the **shared-input** pair, which pins that the two sides compute the importer's occupied bare names identically — codegen reads them after Pass 0's helper renames, the verifier from the pre-transform AST, and a non-generic `where`-helper named `gen2` made the same imported generic bare-name-owning on one side and qualified-only on the other. The idempotence of that derivation is asserted directly across BOTH Pass-0 transforms and their composition — over a fixture carrying every helper shape it distinguishes (non-generic under non-generic, under a generic parent, under a generic helper, and a generic helper), since a fixture missing one would let a partial assertion look total — with each shape's membership pinned individually beside it, because idempotence alone would hold for a derivation that answered the same WRONG set every time. Two more families close the visibility dimension: the **transitive** one, driven through the production `ModuleResolver` (a hand-built `ResolvedModule` defaults `direct=True` and would never reach the path), asserting that a module reached only transitively has ALL its generics qualified-only — the entry's namespace does not hold them at all; and the **user-written qualified call** (`deep::gen(true)` where the importer declares its own `gen`), which must key its instantiation to the module's declaration rather than to whoever owns the bare name | -| `test_ambiguous_import_refusal_1304.py` | 40 | 1,197 | #1304 — two imports supplying one bare name are refused, in every namespace. Spec §8.5 ordered a local declaration against an import and gave the qualified form for a clash it hides, but defined no order between two IMPORTS of one name, and neither did the implementation: a module importing two dependencies that each export `forall fn gen` — one `@Int`-returning, one `@Bool` — bound its bare call to whichever supplier a set of module paths yielded first, so one unchanged file was check-green on one run and `[E121] body has type Bool` on the next (at the branch point: accepted on hash seeds 0, 2 and 3, rejected on 1, 4, 5, 6 and 7). The load-bearing cells are the DETERMINISM ones — each import order checked in four fresh subprocesses under four `PYTHONHASHSEED` values, asserting one byte-identical verdict including message and location, which the base tree cannot satisfy and which a merely deterministic PICK would also fail (the refusal is what removes the choice). Around them: the refusal is definition-gated like the E608 rail it generalises, so an unused clash is still refused and swapping a bare call for the qualified form does not lift it; the two escape hatches — a local declaration (§8.5.2) and a selective import — are asserted to their RUNTIME VALUE, since a disambiguation resolving to the wrong supplier is silent at check and wrong at run; four non-ambiguous controls (one supplier, disjoint names, a private namesake, an out-of-filter namesake) hold the refusal to bare-name ambiguity; and the emitted code is held to a typecheck-phase range, because reusing a codegen code would carry #1304's own complaint — a scope question enforced at the wrong layer — into the fix. A subprocess canary pins that every fresh interpreter measures this checkout | +| `test_ambiguous_import_refusal_1304.py` | 40 | 1,202 | #1304 — two imports supplying one bare name are refused, in every namespace. Spec §8.5 ordered a local declaration against an import and gave the qualified form for a clash it hides, but defined no order between two IMPORTS of one name, and neither did the implementation: a module importing two dependencies that each export `forall fn gen` — one `@Int`-returning, one `@Bool` — bound its bare call to whichever supplier a set of module paths yielded first, so one unchanged file was check-green on one run and `[E121] body has type Bool` on the next (at the branch point: accepted on hash seeds 0, 2 and 3, rejected on 1, 4, 5, 6 and 7). The load-bearing cells are the DETERMINISM ones — each import order checked in four fresh subprocesses under four `PYTHONHASHSEED` values, asserting one byte-identical verdict including message and location, which the base tree cannot satisfy and which a merely deterministic PICK would also fail (the refusal is what removes the choice). Around them: the refusal is definition-gated like the E608 rail it generalises, so an unused clash is still refused and swapping a bare call for the qualified form does not lift it; the two escape hatches — a local declaration (§8.5.2) and a selective import — are asserted to their RUNTIME VALUE, since a disambiguation resolving to the wrong supplier is silent at check and wrong at run; four non-ambiguous controls (one supplier, disjoint names, a private namesake, an out-of-filter namesake) hold the refusal to bare-name ambiguity; and the emitted code is held to a typecheck-phase range, because reusing a codegen code would carry #1304's own complaint — a scope question enforced at the wrong layer — into the fix. A subprocess canary pins that every fresh interpreter measures this checkout | | `test_module_generic_collision_1281.py` | 20 | 797 | #1281 — E608 must not refuse two modules' PROVABLY DISTINCT generics. A generic emits nothing under its bare name, and since #1274 its clones live in a namespace chosen per OWNER, so the diamond (`base` public, `mid1` private, both named `gen`) cannot overwrite anything — and was refused outright, with `vera verify` returning rc=0 beside the refusal. Each door now answers its own module's generic (555 + 111) against the standalone oracles, and the emitted module carries `mod$…$mid1$gen$Bool` and `mod$…$base$gen$Bool` with nothing in the entry's bare clone namespace. The relaxation is gated on three conditions, each with its own cell: both declarations are top-level generics (a generic beside a non-generic keeps the refusal), at most one owns the bare name (asked of the predicate directly, since end to end the ambiguity gate catches that shape first), and no namespace can name both — a module importing two dependencies that each export `gen` would resolve its own bare call to one of them, and spec §8.5 refuses the name outright rather than ordering the two imports (issue 1304). The CHECKER reports that (E155) and this rail is its backstop, so both cells drive the shape through `build_multi_module_past_check` and assert both layers: a rail no test can reach is one that can rot into a relaxation nobody measures. A namespace that declares its own `gen` is not ambiguous however many dependencies export one (§8.5.2). The registration half — a qualified-only generic contributing no bare `_fn_sigs` or `_fn_ret_type_exprs` entry — is pinned by two STRUCTURAL cells, one per table, and its docstring says why: with #1299's scope narrowing in place both withholdings are defence in depth, reverting them leaves every suite and the whole conformance corpus green, and they are kept only because four consumers read those tables per NAME and nothing but their current internals stops each from picking one | | `test_lexical_fn_scope_1299.py` | 56 | 1,554 | #1299 — codegen's bare-call ownership table must be the CALL SITE's lexical scope. The #1284 predicate is one rule over two tables, and codegen's was `set(_fn_sigs)`: every symbol the whole compilation absorbed, including names the compiling body cannot see, so a bare `get(())` the checker resolved to a `State` operation was lowered as a call to some other declaration. Four routes, all check-green — an imported module's **private** `get`, a **public** one a selective import excludes, a `where` helper of a **`forall` parent** (which keeps a bare key beside its clone-qualified one where a non-generic parent's does not), and the ability operation `show`, which E151 does not reserve and which reaches the same table through the INTRINSIC gate rather than the op one. Where the widths agreed the module loaded and answered the invisible declaration's value (7007 for the cell's 42007); where they differed it failed to load; the generic-`where` route is always loud (`unknown func $get`). Every expected value is the checker's, PROVEN by a type oracle rather than assumed — the invisible `get` returns `@Bool` while the caller returns `@Int` from it and checks green — and each route carries a rename control. The visibility matrix (public/private × in-filter/excluded/wildcard × shadowed/unshadowed × direct/transitive) asserts the verify verdict and the runtime value together per cell. The two directions are pinned at once: the sibling loses the name, the generic TEMPLATE keeps it (asserted on the emitted instruction stream, since monomorphization supersedes the template and a value assertion would be green either way), and a lifted closure — compiled through its own `WasmContext` — inherits its parent's scope. Four table invariants sit beside them: the scoped set is a subset of the registry, every `$`-bearing key stays in it, prelude names stay in it, and every emission door supplies a declaration its own helpers | | `test_phantom_generic_instances_1271.py` | 14 | 358 | #1271 — discovery inside a still-generic scope must not instantiate a callee at an ENCLOSING scope's type VARIABLE. `pick(@U.1, @U.0)` inside `forall fn helper` bound `pick`'s variable to the NAME `U`, so a `pick$U` clone was emitted whose parameter has no WASM type and which the compilability pass then skipped with a loud `[E604]` — the noise that kept #1223's shapes out of the conformance suite. Drives the four #1223 fixtures plus a mutual-recursion shape (two sibling generic helpers under a generic parent, whose phantoms include one arriving through a callee's declared RETURN type, `leaf$W`), asserting on ONE compile that no clone is keyed by a type variable, that no E602/E604/E605 skip is emitted, AND that the genuinely concrete clone is still there — the third assertion being what separates the filter from an over-filter that would take the real instantiation with it. Plus the **primitive-spelled binder** matrix (`forall`, ``, ``, ``), each row instantiating a sibling at exactly the type its binder is spelled like — a shared `idw(5)` would have let every row but `Int` pass for free — asserted on the clone set AND on the program still running; with the `Q`-binder control that keeps a genuine type variable filtered, so the fix cannot degenerate into "never filter". That control CREATES a live phantom candidate — a generic helper under a generic parent, handing its callee an argument typed by its own binder — because a control that merely fails to create one holds under any filter including none; mutation-checked by disabling the filter, which turns it red | @@ -210,7 +210,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_markdown.py` | 94 | 610 | Markdown parser: block/inline parsing, rendering, round-trips, edge cases | | `test_lsp.py` | 146 | 2470 | LSP transport + coordinate layer (#222 Phase C) and language features (#222 Phase D): parametrized code-point↔UTF-16 goldens incl. astral-plane fixtures and surrogate-pair snapping, Span (1-based, exclusive-end) and SourceLocation (0-based col) → LSP Range conversions, point→token-range widening, DocumentStore open/change/close + index invalidation, an in-process handler-drive test, and one stdio end-to-end round-trip against the real `vera lsp` subprocess (initialize → didOpen → shutdown → exit) pinning serverInfo + textDocumentSync capabilities; plus the Phase D feature suite — parse-error single-diagnostic path, type-error verification short-circuit, tier=3 in E520 diagnostic data, per-function tier Hint synthesis (and its suppression for functions with violated obligations), smallest-enclosing-span hover, De Bruijn slot goto (most-recent-parameter jump, out-of-range None, off-slot None, and the #1208 keying pins: a parameterised reference resolves, an alias-spelled parameter is reachable from a canonically-spelled reference, and a `forall` variable shadowing a module alias lands on the right parameter), and typed-hole completion (inside/after hole, away-from-hole None); plus the Phase E speculativeEdit suite — identical-text all-unchanged, breaking edit surfaces newly_undischarged (violated nat_sub) with canonical state untouched, strengthening edit surfaces newly_discharged, parse/type errors report ok:false, deleted functions report removed, proof_delta purity; plus the Phase F1 proposeEdit suite — the apply gate (clean and strengthening edits apply, breaking and non-compiling edits refuse), force overriding both gates with the delta still reported, wiring against a structural fake server (apply round-trip with exact full-document replacement range, refuse touches no canonical state, unopened-URI clamp sentinel), and full-document-range goldens (trailing-newline virtual line, UTF-16 end column); plus the Phase F2 strengthenContract suite — splice goldens (first-clause-only replacement with byte-identical remainder, ensures variant, unknown-fn None), the call-site audit pin (tightened precondition refused with newly_undischarged call_pre items, canonical state untouched), provable-ensures strengthening applies, and the three splice-target refusal paths (no analysis, unparseable document, unknown function); plus the Phase F3 addEffect suite — transitive-caller closure goldens (diamond in declaration order, leaf, unknown-fn None, recursion appears once), handler bounding (#725: a caller discharging the effect around its only call site drops out of the closure and is not rewritten, while a second unhandled path, a call in a handler clause, a handler for another effect, a handler naming a different instance of the same effect (`handle[State]` against a `State` propagation, which the checker does not discharge — end-to-end that caller must still be rewritten and the candidate must still apply, with the matching `State` propagation against the same fixture as the positive control that this handler key does prune something), and a bare `where`-helper call all keep it in, as does a refinement type argument at either depth (`Exn<{ @Int \| p }>` and `Exn>` render as their bare base type but discharge nothing of `Exn` — also pinned end-to-end), while an unparameterised `handle[IO]` does bound an `IO` propagation, a whitespace-spelled `State< Int >` request still bounds, and nesting bounds in either order (a matching handler inside a foreign one, a foreign one inside a matching one) — plus a key-level pin that `handle[Mod.IO]` keeps its module, and the effect-less query pinned handler-unaware; plus the two boundary pins the review added — a call in the handler's STATE INITIALISER keeps its edge, since the initialiser is evaluated in the enclosing scope before the handler is installed (pinned beside the E125 the checker raises there against a `pure` caller, with the identical call in the handler body clean as the contrast), and an alias-spelled handler does not bound a `State` propagation though the checker discharges it (`handle[State]` with `type MyAlias = Int` — the spelling comparison's under-prune, #1292, with the alias-spelled request as the control that does prune)), effect-row rewrite goldens (pure to singleton set, source-preserving append, already-present None, base-name identity blocking State next to State), diamond propagation applying one multi-site candidate with the bystander untouched, mixed append/replace rows with already-satisfied callers skipped, the fully-satisfied no-op shape, and the two refusal paths; plus the #728 instruction-contract suite — the LSP message carries description, rationale, and the Fix: paragraph (also pinning single E501 emission at the LSP surface), and a bare diagnostic maps to the description alone | | `test_browser.py` | 413 | 5,094 | Browser parity: Python/wasmtime vs Node.js/JS-runtime output equivalence across IO, State, contracts, Markdown, Regex, and the examples the browser target can execute (two explicit lists in the file, not the whole `examples/` directory — interactive stdin, file IO, `DB` and the non-standalone `modules` example are excluded with their reasons recorded); plus the #349 `runtime.mjs` coverage battery — per-value-type and per-key-type Map variants, per-element-type Set variants, cold `Decimal` branches (exact-zero sign, negative-shift division, `decRoundPlaces` special cases, non-finite storage), `readJson`/`json_stringify` across every Json ADT tag, the Regex/Json `Result.Err` arms, and nested-Markdown walks. Two operations carry a canonical form the specification states rather than merely agreeing across the hosts, so their batteries assert more than equality: `json_stringify` (spec §9.7.1) pins the expected string on every Json ADT tag and on the number-rendering boundaries, checks three-pass idempotence, checks that a non-finite `JNumber` fails on both hosts *and* prints nothing, pins the object key orders an ordinary JS object cannot carry (array-index keys, which ES enumeration hoists to the front in ascending numeric order, and a `__proto__` key, whose assignment writes a prototype instead of a field) on both a parsed and a program-built object, and checks the reference host's own number rendering differentially against a real `JSON.stringify` over doubles drawn from raw bit patterns; `md_render` (§9.7.3) pins the expected render, re-renders it to prove the fixed point, runs the round-trip property over a corpus carrying the container and multi-line shapes a flat corpus misses, and renders `MdBlock` values the test builds directly, since several renderer rules — a container's child separator, an empty container, a code span wider than one backtick — are unreachable through `md_parse`. plus the #1306/#1308 accept-domain battery (`TestBrowserJsonAcceptDomainParity1306_1308`), which runs one `.wasm` under both runtimes and compares the WHOLE stdout — arm taken and `Err` message together — over the JavaScript constants, over numbers that overflow to an infinity in both their exponent and integer spellings, and over lone surrogates at every position a string can occupy, with the expected sentences imported from `vera/wasm/json_serde.py` so `runtime.mjs`'s hand-copied duplicates are held against the originals, beside acceptance controls (matched pairs, finite boundary values, underflow to `0`, `"NaN"` as a string value), a ten-case host-native-message battery pinning that neither the substitute-and-re-parse probe nor the value-start constraint hijacks an unrelated syntax error (`-NaN` is the case needing both rules — the substitution alone turns it into `-0` and manufactures a refusal the reference host never makes), a precedence case fixing that a non-finite constant outranks a lone surrogate on both hosts though each reaches that answer by a different route, and a document-order case fixing that overflow and lone surrogate are resolved by one walk rather than by two per-host precedence rules that would agree on every single-violation document; `md_parse` itself is not yet at parity and its remaining divergence classes are a tracked bug ([#1301](https://github.com/aallan/vera/issues/1301)); the suite pins the shapes the two implementations agree on | -| `test_conformance.py` | 1220 | 144 | Parametrized conformance suite: parse, check, verify, run, format idempotency across 244 programs; a negative entry fails at the stage `expected_error_stage` names (`check`, the default, or `compile` — which also asserts the program type-checks cleanly first) | +| `test_conformance.py` | 1220 | 154 | Parametrized conformance suite: parse, check, verify, run, format idempotency across 244 programs; a negative entry fails at the stage `expected_error_stage` names (`check`, the default, or `compile` — which also asserts the program type-checks cleanly first) | | `test_prelude.py` | 29 | 585 | Prelude injection: Option/Result/array operation detection, combinator shadowing, type aliases, the reserved namespace every injected alias declaration lives in — checked against the checker's own E154 regex rather than a second spelling of the rule, since an alias the prelude injects outside it is one codegen resolves and the checker leaves opaque (#1184/#1221) — end-to-end compilation | | `test_checker_apply_fn.py` | 18 | 455 | #854 — `apply_fn` as a checker special form: zero-warning pins (API + CLI `--json` + closures.vera), E201 arity / E202 type / non-function-first-arg errors, E122/E125 effect-row enforcement for applied fn values, E151 redefinition rejection, variadic two-param application, prelude combinator regression pins | | `test_prelude_diagnostics.py` | 8 | 271 | #851 — prelude combinator skip-warnings: unreferenced-prelude E602/E604 suppression (zero-warning minimal compile, API + CLI `--json`), `` origin attribution for referenced-but-skipped combinators (text + `to_dict`), transitive reference scan, and user-fn warning locations pinned unchanged | @@ -221,11 +221,11 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_build_site.py` | 46 | 698 | Site-asset tooling — `_abs_links` rewriting (relative links, fenced-block immunity incl. inline backticks and tilde fences, http/https/fragment pass-through, Vera effect syntax not mis-parsed), `build_site` `` stability (preserve/refresh keyed on URL-structure change), `check_site_assets` sitemap staleness (missing / date-only-clean / structural-stale), and the #538 leak guard (vera:skip fence annotations stripped from generated `docs/SKILL.md` / `docs/llms-full.txt`, with a non-vacuous precondition that the source carries annotations); plus the #1154 `check_fact_coherence()` suite — index.html↔index.md fact extraction and divergence detection | | `test_builtin_typevar_collision_970.py` | 61 | 811 | #970 a user `forall` type-var name colliding with a built-in generic's internal name (`T`/`E`/`A`/`B`/`K`/`U`/`V`): focused check/verify pins for the compound-argument shapes (`@Array>`, `@Result>`, `@Map>`) plus a collide-vs-control differential battery over every generic-builtin family and contract/where-helper position. Also pins marker-strip (the `#b` namespacing marker must never reach an E205/E202 diagnostic), a registry-consistency pin (every built-in ability-constraint `type_var` stays a member of its `forall_vars`), the dual completeness-gap pinned in both argument orders, a tier-split equality pin, and the #1069 leaked-placeholder message-rendering sweep (a stripped built-in var renders as `?`, not a bare letter, at every reachable actual-type slot: the mismatch sites plus the operator/index/interpolation family, `assert`/`assume`, `if` condition and branches, and the contract/refinement predicates — one parametrized row per converted render slot, with the provably-unreachable sites documented in the class docstring) | | `test_check_changelog_updated.py` | 68 | 712 | `check_changelog_updated.py` unit + end-to-end tests: file classification (incl. file-style exact-match vs directory-style prefix-match), CHANGELOG diff parsing with `[Unreleased]` section tracking, bare-heading rejection, and full-file context (regression test for bullets far below the heading), `Skip-changelog:` trailer detection, temp-repo integration covering substantive/exempt/label/trailer paths, and `GIT_*`-env hermeticity of the temp-repo fixtures (regression for the pre-commit-hook env leak) | -| `test_release.py` | 62 | 786 | Release policy and registry verification (#481): strict project-name and version parsing/comparison, version-bump/TestPyPI/recovery planning, exact confirmation and immutable-tag guards, first-parent version-introduction discovery, package-change recovery refusal, non-empty CHANGELOG extraction, one-wheel/one-sdist SHA-256 manifests, malformed registry-response handling, missing/filename/hash propagation retries, exact filename/hash verification, and CLI dispatch/GitHub-output wiring. An autouse fixture scrubs hook-exported `GIT_*` variables so the tmp-repo git calls (fixture helpers and `release.py`'s own) never resolve to the developer's repository when the suite runs inside a pre-commit hook. | -| `test_check_doc_counts.py` | 89 | 987 | `check_doc_counts.py`'s pure per-document checks: KNOWN_ISSUES refactoring line counts (±10% tolerance band incl. the exact-boundary case, drift detection, empty-file citation, hyphenated paths, missing file/section/rows, the #419 empty-section sentinel + its cannot-mask-a-malformed-table dual), HISTORY version-row format (issue-link limit, ` — ` separator rejection, dateless-row and prose exemption, line-number reporting), the TESTING.md tests breakdown (parts summing to the collected total, a self-consistent-but-stale row, and the reworded-row error), and vera/README.md's Test Suite counts (all four checked independently — mutation-validated by dropping each citation in turn — plus the reworded-paragraph error, a thousands-separator case pinning that every one of the four counts is read comma-tolerantly, and the two section-anchoring cases — a reworded paragraph with decoy counts in a later section, and a renamed heading, both of which must fail loud rather than match across the section boundary). The reworded case is a test in its own right for both new checks: a pattern that matches nothing must be an error, or rewording the sentence silently switches the gate off. Also the release count (README's status line and HISTORY's total against each other and against `git tag`: the matching case, the one-ahead release cut that `release.yml` has not tagged yet, that +1 being the ONLY slack once the version is tagged, the two-behind drift that actually shipped, per-document reporting, and a tagless checkout standing the oracle down without standing down the cross-check), plus the tag reader itself against real repositories built in `tmp_path` — release tags read, `nightly`/`-rc1` not counted as releases, and both no-evidence answers (`None` rather than `[]`, since an empty list would read as zero releases and make every documented count wrong) for a tagless checkout and a directory that is not a repository at all. CONTRIBUTING.md's pre-commit hook count is checked the same way, reworded-sentence case included; and the CI-pipeline lint row against `ci.yml`'s lint job — a matching row, a step present in CI but absent from the row (the drift that shipped), a row entry CI no longer runs, the same set in a different order, a reworded row and a renamed job (both errors, not skips), that only the lint job is read rather than the whole workflow, and the shipped pair both clean and red with one entry dropped | -| `test_grammar_alignment.py` | 89 | 706 | `check_grammar_alignment.py` gate ([#683](https://github.com/aallan/vera/issues/683)): both extractors (Lark headers with the `?`/`!`/`_` markers stripped, template parameters and rule priorities tolerated, and `-> alias` names deliberately not collected; spec headers from ```ebnf fences only), the allowlist arithmetic in all three directions — unwaived drift, a spent entry both files now have, and an entry whose premise broke — one case per waiver proving the fact it rests on is actually checked, the name-deleted-from-both-files case that must not read as agreement, a mutation restoring the spec's old `assert_stmt` name, non-vacuous extraction, and the false positive the issue itself rested on: `qualified_call` and `module_call` are spec headers Lark expresses as aliases, and must never be reported as drift. Three pin the premise checks against ways they used to pass vacuously: an alias surviving only inside a `//` comment must not hold its waiver up, an alias that moved to another rule must fail the waiver naming `fn_call`, and a spent waiver whose premise also broke must yield one instruction rather than two opposite ones | +| `test_release.py` | 62 | 798 | Release policy and registry verification (#481): strict project-name and version parsing/comparison, version-bump/TestPyPI/recovery planning, exact confirmation and immutable-tag guards, first-parent version-introduction discovery, package-change recovery refusal, non-empty CHANGELOG extraction, one-wheel/one-sdist SHA-256 manifests, malformed registry-response handling, missing/filename/hash propagation retries, exact filename/hash verification, and CLI dispatch/GitHub-output wiring. An autouse fixture scrubs hook-exported `GIT_*` variables so the tmp-repo git calls (fixture helpers and `release.py`'s own) never resolve to the developer's repository when the suite runs inside a pre-commit hook. | +| `test_check_doc_counts.py` | 89 | 1,002 | `check_doc_counts.py`'s pure per-document checks: KNOWN_ISSUES refactoring line counts (±10% tolerance band incl. the exact-boundary case, drift detection, empty-file citation, hyphenated paths, missing file/section/rows, the #419 empty-section sentinel + its cannot-mask-a-malformed-table dual), HISTORY version-row format (issue-link limit, ` — ` separator rejection, dateless-row and prose exemption, line-number reporting), the TESTING.md tests breakdown (parts summing to the collected total, a self-consistent-but-stale row, and the reworded-row error), and vera/README.md's Test Suite counts (all four checked independently — mutation-validated by dropping each citation in turn — plus the reworded-paragraph error, a thousands-separator case pinning that every one of the four counts is read comma-tolerantly, and the two section-anchoring cases — a reworded paragraph with decoy counts in a later section, and a renamed heading, both of which must fail loud rather than match across the section boundary). The reworded case is a test in its own right for both new checks: a pattern that matches nothing must be an error, or rewording the sentence silently switches the gate off. Also the release count (README's status line and HISTORY's total against each other and against `git tag`: the matching case, the one-ahead release cut that `release.yml` has not tagged yet, that +1 being the ONLY slack once the version is tagged, the two-behind drift that actually shipped, per-document reporting, and a tagless checkout standing the oracle down without standing down the cross-check), plus the tag reader itself against real repositories built in `tmp_path` — release tags read, `nightly`/`-rc1` not counted as releases, and both no-evidence answers (`None` rather than `[]`, since an empty list would read as zero releases and make every documented count wrong) for a tagless checkout and a directory that is not a repository at all. CONTRIBUTING.md's pre-commit hook count is checked the same way, reworded-sentence case included; and the CI-pipeline lint row against `ci.yml`'s lint job — a matching row, a step present in CI but absent from the row (the drift that shipped), a row entry CI no longer runs, the same set in a different order, a reworded row and a renamed job (both errors, not skips), that only the lint job is read rather than the whole workflow, and the shipped pair both clean and red with one entry dropped | +| `test_grammar_alignment.py` | 89 | 713 | `check_grammar_alignment.py` gate ([#683](https://github.com/aallan/vera/issues/683)): both extractors (Lark headers with the `?`/`!`/`_` markers stripped, template parameters and rule priorities tolerated, and `-> alias` names deliberately not collected; spec headers from ```ebnf fences only), the allowlist arithmetic in all three directions — unwaived drift, a spent entry both files now have, and an entry whose premise broke — one case per waiver proving the fact it rests on is actually checked, the name-deleted-from-both-files case that must not read as agreement, a mutation restoring the spec's old `assert_stmt` name, non-vacuous extraction, and the false positive the issue itself rested on: `qualified_call` and `module_call` are spec headers Lark expresses as aliases, and must never be reported as drift. Three pin the premise checks against ways they used to pass vacuously: an alias surviving only inside a `//` comment must not hold its waiver up, an alias that moved to another rule must fail the waiver naming `fn_call`, and a spent waiver whose premise also broke must yield one instruction rather than two opposite ones | | `test_check_examples_run.py` | 79 | 1,315 | `check_examples_run.py`, the harness gate that RUNS the examples. Five separable parts, each in both directions. **The coverage rule** — the shipped tables cover the shipped corpus exactly, and an unclassified example, a stale `RUN_SPECS` or `SKIPS` key whose file is gone, a name in both tables, and a skip citing an undocumented property are each an error; the empty corpus is an error too, since a glob that stops matching would otherwise report success over zero programs. Plus the specs' own well-formedness: every named entry point is `public` in its example, every no-main example pins one (or `vera run` would fall back to an arbitrary first export), and no skip property is unused. **The runner** — a seeded `tmp_path` corpus proves it goes red on a program that type-checks and compiles but traps at run time, green on one that does not, and reports only the broken member of a mixed pair; a fixture whose first export is clean and whose named one traps proves `spec.fn` is actually honoured rather than ignored; a writer program proves each run gets a scratch working directory, so a gate run leaves nothing beside the examples. **The TESTING.md cross-check** — missing row, extra row, rename (reported naming both sides), wrong disposition and wrong skip property are errors, the parse stops at the next heading so a row-shaped line in a later section is not swept in, and both a reworded heading and a heading whose table has vanished fail loud rather than finding nothing to compare. **The output signal** -- the second half of the two-signal discipline `check_examples.py` established: the fallback note that `vera run` prints when it cannot use the named entry point is a failure even at exit 0, an absent `expect` sentinel is a failure even at exit 0, and a spec without one asserts nothing about output; end to end, a privatised `main` and a program that completed down a graceful arm each go red, and the same program passes once its own output is the sentinel, so the check reads the output rather than always failing. Which specs must carry a sentinel is **derived** from what each example declares — a resource effect in a function's effect row, or a call to a resource operation, both validated against the live effect registry so a renamed effect or op fails loudly — and asserted equal to the specs that have one, in both directions; the previous hard-coded triple could not see a fourth such example arriving without one. The runner's use of BOTH streams is pinned structurally -- `vera run` writes the note to stderr and nothing there on a clean exit, so no fixture can distinguish reading both streams from reading stdout alone, and a tripwire wired to the wrong stream is no tripwire. Also the hermetic-environment property: an ambient `VERA_DB_URL` or provider key is stripped so a gate run cannot be pointed at a real database or turned into a billed API call, a fixture spec puts its own URL back, and every neutralised name is checked to be one `vera/runtime/` actually reads | -| `test_check_corpus_differential.py` | 50 | 861 | `check_corpus_differential.py`, the burndown instrument that compiles the corpus at two revisions. The pure pieces only — the real two-revision run costs minutes and is not a test. **Classification**, all four verdicts: identical, WAT differs, and each one-sided compile failure as its own kind, since a compilability reversal reported as a text difference is the mis-description the instrument exists to avoid; failing at both revisions is not a mover and is counted separately, so a green run states how much of it was vacuous. **Enumeration** — recursive, keyed by repo-relative POSIX path, and an empty corpus is an error rather than a clean run over nothing. **The canary** — each side must import the compiler it was pointed at, so a side silently resolving to the venv's editable install cannot compare a revision against itself; an import failure and a foreign compiler are different messages. **Reporting** — every mover named with its reason, the exit code, the `--json` shape, and a program missing from one side reported rather than dropped. One test asserts the instrument is absent from `.pre-commit-config.yaml`, so the docstring's claim cannot rot | +| `test_check_corpus_differential.py` | 52 | 895 | `check_corpus_differential.py`, the burndown instrument that compiles the corpus at two revisions. The pure pieces only — the real two-revision run costs minutes and is not a test. **Classification**, all four verdicts: identical, WAT differs, and each one-sided compile failure as its own kind, since a compilability reversal reported as a text difference is the mis-description the instrument exists to avoid; failing at both revisions is not a mover and is counted separately, so a green run states how much of it was vacuous. **Enumeration** — recursive, keyed by repo-relative POSIX path, and an empty corpus is an error rather than a clean run over nothing. **The canary** — each side must import the compiler it was pointed at, so a side silently resolving to the venv's editable install cannot compare a revision against itself; an import failure and a foreign compiler are different messages. **Reporting** — every mover named with its reason, the exit code, the `--json` shape, and a program missing from one side reported rather than dropped. One test asserts the instrument is absent from `.pre-commit-config.yaml`, so the docstring's claim cannot rot | | `test_check_editor_grammars.py` | 20 | 249 | `check_editor_grammars.py` gate ([#1156](https://github.com/aallan/vera/issues/1156)): the registry read (every effect in, every ability out, and the four names the grammars actually drifted on present so the set checked is non-vacuous), the word-boundary presence test across all three grammar formats (JSON, plist XML, Vim keyword list) including the prefix pair `Http`/`HttpServer` in both directions, the deliberate comment-mention false pass, a metacharacter pair that only passes when the name is matched literally (`A.B` present, `A0B` not), and the empty registry; the gate's primary path end to end — a listed grammar with an effect stripped out, and a listed README with one stripped out of its prose bullet, each red against an otherwise-clean mirrored tree; the completeness guard — a grammar discovered under `editors/` but absent from `GRAMMARS` fails that same tree, over three discovery routes (`.el` outside a syntax directory, a tree-sitter `.scm` query set, a `.tmLanguage.json` filed anywhere but `syntaxes/`); and the registry's provenance, run as a subprocess against a throwaway checkout whose `vera` package names an effect the grammars do not, which is the only way to see that the list comes from the tree being checked rather than from site-packages. The shipped grammars and READMEs are currently clean | | `test_check_explicit_encoding.py` | 54 | 254 | `check_explicit_encoding.py` gate (#645): flags text-mode `open()` / `read_text()` / `write_text()` **and** `subprocess.run/Popen/check_output(..., text=True)` captures missing an `encoding="utf-8"` literal (rejects non-literal / non-UTF-8 values), skips binary/bytes-mode calls, honours the `# encoding-exempt` opt-out, and asserts the shipped repo is clean | | `test_check_limitations_sync.py` | 6 | 108 | `check_limitations_sync.py` section extraction: table-rows-only issue harvesting, prose-link exemption, bounding at the next second-level heading, `None` for absent or sub-level headings so renamed sections fail loudly; plus the #852 fail-loud rule: an UNKNOWN issue state under `--check-states` (gh missing / auth failure / timeout) is an error, never a silent pass | diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 880d69b5..8802ce1c 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -3265,7 +3265,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 11,951 tests, including a 244-program conformance suite +- 11,953 tests, including a 244-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/tests/test_ambiguous_import_refusal_1304.py b/tests/test_ambiguous_import_refusal_1304.py index 6944fc73..ae2fd0be 100644 --- a/tests/test_ambiguous_import_refusal_1304.py +++ b/tests/test_ambiguous_import_refusal_1304.py @@ -154,6 +154,11 @@ def _run_check_json( encoding="utf-8", env={**os.environ, "PYTHONHASHSEED": seed, "PYTHONPATH": str(_VERA_ROOT)}, + # A seed that drives the checker into a non-terminating + # resolution loop is the exact failure this file exists to + # find, and an unbounded wait reports it as a hung suite + # rather than as a finding (#1330 review). + timeout=300, check=False, ) diff --git a/tests/test_check_corpus_differential.py b/tests/test_check_corpus_differential.py index 7787c3bc..14ab9552 100644 --- a/tests/test_check_corpus_differential.py +++ b/tests/test_check_corpus_differential.py @@ -245,6 +245,40 @@ def test_a_program_missing_from_one_side_is_reported_not_ignored( assert c.identical == 1 assert c.compared == 1 + def test_a_program_the_head_side_never_reported_is_unreported_too( + self, + ) -> None: + """The mirror direction (#1330 review). + + `unreported` is a symmetric difference, so both directions are + one expression — but only the base-missing one was exercised, + and an implementation that iterated the head map alone would + pass every other cell in this class while under-reporting the + corpus. This is the direction that hides a truncated HEAD run, + which is the worse of the two: the base side is a fixed + revision, the head side is the tree under test. + """ + c = _MOD.compare( + {"a.vera": _ok("same"), "b.vera": _ok("x")}, + {"a.vera": _ok("same")}, + "origin/main", + ) + assert c.unreported == ["b.vera"] + assert c.identical == 1 + assert c.compared == 1 + + def test_both_sides_missing_a_different_program_are_both_reported( + self, + ) -> None: + """Neither direction shadows the other.""" + c = _MOD.compare( + {"a.vera": _ok("same"), "base_only.vera": _ok("x")}, + {"a.vera": _ok("same"), "head_only.vera": _ok("y")}, + "origin/main", + ) + assert c.unreported == ["base_only.vera", "head_only.vera"] + assert c.compared == 1 + # --------------------------------------------------------------------------- # Report, exit code, JSON diff --git a/tests/test_check_doc_counts.py b/tests/test_check_doc_counts.py index 077f7f0b..abc38f42 100644 --- a/tests/test_check_doc_counts.py +++ b/tests/test_check_doc_counts.py @@ -711,7 +711,15 @@ def test_a_count_dropped_from_the_line_is_an_error_not_a_skip(self) -> None: text = _STATUS.replace("229 conformance programs, ", "") errors = _MOD.check_project_status(text, 11134, 229, 42, 14) assert len(errors) == 1 - assert "could not find" in errors[0] + # Both branches say "could not find", so the phrase alone cannot + # tell "the line is gone" from "one figure on it is gone" — and + # this cell is about the second. Naming the figure is the + # discriminator (#1330 review). + assert "could not find the conformance programs count" in errors[0] + # The missing-LINE branch quotes the pattern it looked for; the + # missing-FIGURE branch names the figure. Both mention the line, + # so that phrase is not the discriminator. + assert "Python code coverage" not in errors[0] def test_the_counts_are_read_from_the_status_line_only(self) -> None: """A decoy elsewhere in the file must not satisfy the gate.""" @@ -760,6 +768,13 @@ def test_each_category_is_gated(self) -> None: _DUAL_ROW, 168, _split(families=42, no_main=7, nondeterministic=2) ) assert len(errors) == 3 + # Counted AND attributed: three errors are also what a reporter + # that swapped two category messages returns, and the three + # values are distinct, so each can name its own (#1330 review). + joined = "\n".join(errors) + assert "families: doc says 43, a live run has 42" in joined + assert "no_main: doc says 6, a live run has 7" in joined + assert "nondeterministic: doc says 1, a live run has 2" in joined def test_the_split_must_sum_to_the_run_level_total(self) -> None: """Three consistent-looking numbers that do not add up is drift.""" diff --git a/tests/test_closure_boundary_widths_1255_1256_1269.py b/tests/test_closure_boundary_widths_1255_1256_1269.py index 4861d7ef..4ee6fef5 100644 --- a/tests/test_closure_boundary_widths_1255_1256_1269.py +++ b/tests/test_closure_boundary_widths_1255_1256_1269.py @@ -771,8 +771,12 @@ def test_the_tag_and_the_thrown_value_agree_on_i32(self) -> None: tags = re.findall(r"\(tag \$exn_\S+ \(param ([^)]*)\)\)", wat) assert tags == ["i32"], tags body = _fn_body(wat, "boom") - assert "i32.const 5" in body, body - assert "i64.const 5" not in body, body + # Token-anchored: a bare substring also matches `i32.const 50` + # and `i64.const 512`, so any later constant beginning with 5 + # would flip either assertion with no width regression + # (#1330 review). + assert re.search(r"\bi32\.const 5\b", body), body + assert not re.search(r"\bi64\.const 5\b", body), body lines = [ln.strip() for ln in body.strip().splitlines()] throw_at = next(i for i, ln in enumerate(lines) if ln.startswith("throw $exn_")) diff --git a/tests/test_conformance.py b/tests/test_conformance.py index 0f7de6f8..471bf9db 100644 --- a/tests/test_conformance.py +++ b/tests/test_conformance.py @@ -99,7 +99,17 @@ def test_check(self, entry: dict) -> None: f"type-check cleanly:\n{pre.stdout}\n{pre.stderr}" ) result = _vera(stage, "--json", path) - payload = json.loads(result.stdout) + try: + payload = json.loads(result.stdout) + except json.JSONDecodeError as exc: + # A stage died before emitting the envelope; without the + # streams this arrives as a bare decode error about an + # empty document (#1330 review). + raise AssertionError( + f"{path.name}: {stage} --json produced no JSON envelope " + f"({exc}).\nstdout:\n{result.stdout}\n" + f"stderr:\n{result.stderr}" + ) from exc codes = [d.get("error_code") for d in payload.get("diagnostics", [])] assert payload.get("ok") is False and expected_error in codes, ( diff --git a/tests/test_grammar_alignment.py b/tests/test_grammar_alignment.py index 084c3e9d..008c6009 100644 --- a/tests/test_grammar_alignment.py +++ b/tests/test_grammar_alignment.py @@ -561,7 +561,14 @@ def test_an_aliased_alternative_is_not_read_as_a_rule_reference(self) -> None: bodies = _MOD.rule_bodies(_lark_lines()) aliases = {alias for rule, alias in _MOD.extract_lark_aliases(_lark_text()) if rule == "fn_call"} - assert "func_call" in aliases, "the alias this cell rests on is gone" + # The complete set, not just the one name falsifiability rests on: + # an `extract_lark_aliases` that regressed to returning only + # `{"func_call"}` would leave both this and the loop below green + # while covering one alias of five (#1330 review). + assert aliases == { + "func_call", "constructor_call", "nullary_constructor_expr", + "qualified_call", "module_call", + }, aliases body = "".join(bodies["fn_call"]) assert "LOWER_IDENT" in body, "positive control: the body was read" diff --git a/tests/test_release.py b/tests/test_release.py index 27e0294d..efd9781d 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -729,10 +729,22 @@ def test_the_release_workflow_passes_the_repository_to_the_notes_step(self) -> N workflow = ( Path(__file__).parent.parent / ".github" / "workflows" / "release.yml" ).read_text(encoding="utf-8") - step = "python scripts/release.py notes" - assert step in workflow, "release.yml no longer invokes the notes builder" - tail = workflow[workflow.index(step) : workflow.index(step) + 400] - assert "--repo" in tail + # The exact invocation, not a proximity window: a 400-character + # slice can be satisfied by a `--repo` belonging to a LATER step, + # and fails on a correct workflow whose `run:` block grows past + # it. This gate is the only thing tying the tested builder to the + # shipped workflow (#1330 review). + invocation = ( + 'python scripts/release.py notes \\\n' + ' --version "$VERSION" \\\n' + ' --repo "$GITHUB_REPOSITORY" \\\n' + " --output release/RELEASE_NOTES.md" + ) + assert invocation in workflow, ( + "release.yml no longer invokes the notes builder with --repo; " + "found:\n" + + workflow[workflow.find("release.py notes") - 40 :][:400] + ) def test_main_manifest(self, tmp_path: Path) -> None: dist = _dist(tmp_path) diff --git a/vera/README.md b/vera/README.md index 544c9d55..f52bbaf2 100644 --- a/vera/README.md +++ b/vera/README.md @@ -753,7 +753,7 @@ The `ERROR_CODES` dict in `errors.py` maps every code to a short description (16 ## Test Suite -Testing spans a **pytest suite** of 11,951 tests across 175 files — compiler-internals unit tests plus a **conformance suite** (244 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. +Testing spans a **pytest suite** of 11,953 tests across 175 files — compiler-internals unit tests plus a **conformance suite** (244 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. See **[TESTING.md](../TESTING.md)** for the comprehensive testing reference -- test file table, conformance suite details, compiler code coverage, language feature coverage, helper conventions, validation scripts, CI pipeline, and guidelines for adding tests. diff --git a/vera/checker/modules.py b/vera/checker/modules.py index b71ccac3..2bc58170 100644 --- a/vera/checker/modules.py +++ b/vera/checker/modules.py @@ -319,7 +319,15 @@ def _reject_ambiguous_imports( (type_clashes, "data type", "a", "E156"), (ctor_clashes, "constructor", "a", "E157"), ): - for name, deps in clashes.items(): + # Sorted, because the docstring above promises sorted name + # order and the producers hand this back in IMPORT order — + # deterministic (measured stable across hash seeds), but not + # what the contract says. Sorting by NAME leaves each name's + # supplier list alone, which is the ordering + # `ambiguous_in` deliberately keeps in import order so the + # report can name the import that completed the clash + # (#1330 review). + for name, deps in sorted(clashes.items()): labels = [".".join(dep) for dep in deps] joined = ", ".join(f"'{label}'" for label in labels[:-1]) joined = f"{joined} and '{labels[-1]}'" diff --git a/vera/prelude.py b/vera/prelude.py index 4113e6a2..f2d40be5 100644 --- a/vera/prelude.py +++ b/vera/prelude.py @@ -18,8 +18,8 @@ import functools import re +from collections.abc import Mapping from types import MappingProxyType -from typing import Mapping from vera import ast from vera.monomorphize import canonicalize_type_aliases diff --git a/vera/slots.py b/vera/slots.py index e2873f05..bae8482c 100644 --- a/vera/slots.py +++ b/vera/slots.py @@ -78,7 +78,7 @@ def bare_call_denotes_user_fn( * the three bare-``FnCall`` inference sites in :mod:`vera.wasm.inference` and :mod:`vera.wasm.context`, so a shadowed name is typed from the function table rather than from the operation's result registry; - * ``_handler_always_throws`` in :mod:`vera.wasm.calls_handlers`, whose + * ``_handle_exn_always_throws`` in :mod:`vera.wasm.calls_handlers`, whose ``throw_installed`` question is the same one for ``Exn``'s operation: a bare ``throw`` in a clause body is the op only where no declaration owns the name; diff --git a/vera/wasm/context.py b/vera/wasm/context.py index 2355ba26..33432d95 100644 --- a/vera/wasm/context.py +++ b/vera/wasm/context.py @@ -728,7 +728,7 @@ def _bare_call_denotes_op(self, name: str) -> bool: so a name the checker resolved to a user declaration is lowered as the ordinary call the checker typed: the clause-inline dispatch, the host-cell intrinsics, the #1233 addressability gate, the three - result-type inference sites, and ``_handler_always_throws``'s + result-type inference sites, and ``_handle_exn_always_throws``'s ``throw_installed`` question, which is the same one for ``Exn``'s operation. From 7601e86365275ce08998ea1d997260f481577025 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Sat, 15 Aug 2026 05:59:45 +0100 Subject: [PATCH 65/69] Track #1331, gate the error-code figures, pin the shape boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `data Array` shadow is filed as #1331 and joins the record: a KNOWN_ISSUES row worded from the measurement rather than the prediction — check-green, then an E602 pair-scrutinee refusal takes the function and its callers and the module ships with no exports; `Map`, `Set` and `Decimal` inert because their built-in width already IS the ADT pointer; pre-existing at v0.1.11; two of at least three derivations corrected and the program still lost its exports. The row records the trap between them too, since it cost a round: the emitter mixin owns `_adt_type_names`, not `_adt_layouts`, so a defensive `getattr` there disables the shadow rule silently instead of failing. The v0.1.13 queue carries it as the fifteenth, and the set-equality gate agrees. `vera/README.md`'s `ERROR_CODES` sentence states three numbers and the oracle read none of them, so the registry could gain a code on any PR and the sentence would drift. All three are gated now — the total, the `E` count, and the claim that the remainder is exactly the two `W` codes — with a reworded-sentence cell so the gate cannot switch itself off. The grammar gate's uncompared production shape was reported as a defect: an empty alternative added to `effect_list` references no new symbol, so `body_drift` stays silent. That is true, and it is the boundary the module docstring and TESTING.md already state — but stating is not measuring, so it is pinned now, with a control proving the silence is about shape and not about `effect_list`. When someone extends the comparison, the cell fails and the two documents get updated with it. Co-Authored-By: Claude --- FAQ.md | 2 +- KNOWN_ISSUES.md | 1 + README.md | 2 +- ROADMAP.md | 5 ++-- TESTING.md | 6 ++-- docs/llms-full.txt | 2 +- scripts/check_doc_counts.py | 46 +++++++++++++++++++++++++++++++ tests/test_check_doc_counts.py | 49 +++++++++++++++++++++++++++++++++ tests/test_grammar_alignment.py | 40 +++++++++++++++++++++++++++ vera/README.md | 2 +- 10 files changed, 146 insertions(+), 9 deletions(-) diff --git a/FAQ.md b/FAQ.md index 728cfd63..f74da008 100644 --- a/FAQ.md +++ b/FAQ.md @@ -279,7 +279,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 11,953 tests, including a 244-program conformance suite +- 11,960 tests, including a 244-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 3d7b1f37..64b1b9ff 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -8,6 +8,7 @@ Defects in shipped compiler, runtime, or tooling behaviour — this table matche | Bug | Issue | |-----|-------| +| A user `data` declaration taking a built-in container's name compiles at the built-in's width. `_type_expr_to_wasm_type` consults the `Array` / `Map` / `Set` / `Decimal` / `Future` branches **before** `_adt_layouts`, so `data Array { Mk(Int) }` is measured as the built-in's `i32_pair` rather than the ADT pointer `i32`. The program is check-green; codegen then refuses the function matching on it with a located E602 ("a scrutinee whose representation is a (ptr, len) pair"), drops its callers, and emits a module with **no exports at all** — `vera run` reports `Available exports: (none)` where a fresh-name control prints its value. Loud, not a wrong answer. Only `Array` misbehaves: `Map`, `Set` and `Decimal` already answer `i32`, the ADT pointer's own width, so they are inert by width-luck — the same coincidence that kept most of [#1309](https://github.com/aallan/vera/issues/1309)'s alias cases hidden. Pre-existing: v0.1.11 carries the identical ordering, and #1309 moved only the *alias* branch above these. The fix needs at least three derivations to agree and two are not enough — `_type_expr_to_wasm_type` (`vera/codegen/core.py`) and `_canonical_wasm_type` (`vera/wasm/inference.py`) were both corrected and the program still lost its exports, so a third site is unlocated. Note the trap between them: the emitter mixin does not own `_adt_layouts` but `_adt_type_names`, so a `getattr(self, "_adt_layouts", {})` there is a silently disabled shadow rule rather than a failure. | [#1331](https://github.com/aallan/vera/issues/1331) | | The nightly stress workflow never exercises `TestHostHandleReclamation573` — its 10 stress-marked instances are deselected from the per-PR suite (`-m 'not stress'` addopts) and `.github/workflows/nightly-stress.yml` runs `pytest -v -m stress tests/test_stress.py`, file-scoped, so no automated lane runs the #573/#575/#576/#706 GC-reclamation battery; the class comment's "Run via `pytest -m stress` or nightly CI" states the unmet intent. Run `pytest -m stress` manually until the workflow adds the file or the comment is re-scoped. | [#1328](https://github.com/aallan/vera/issues/1328) | | Monomorphization's Vera-level type namer has no `IndexExpr` arm, so a generic argument that is an array index drops its caller. `idg(@Array.0[1])` — a `forall` identity called on an element of a local `Array` — is `vera check`-green and then dies at `vera run` with `[E602] Function 'main' body contains unsupported FnCall: call target 'idg$Int' not registered in this module`, leaving `Available exports: (none)`: instantiation discovery (`Monomorphizer._infer_vera_type_name`) answers nothing for the index expression and falls to the phantom-var `Bool` default, while the WASM call-rewrite consultor (`InferenceMixin._infer_vera_type`), which DOES have an `IndexExpr` arm, names `idg$Int` at the call — the two consultors disagreeing, which is the clone-name agreement contract's (#772) failure mode. Loud, never a wrong answer. Measured during #1286's review sweep and unchanged by its fix, which closed the same consultor-parallelism gap for `Block`, `MatchExpr`, `IfExpr` and `HandleExpr` — each an exact one-line mirror of its twin — and deliberately left this one: the rewrite's arm delegates to `_infer_index_element_type`, which resolves chained indexing, type aliases and representation-transparent `Future` payloads against codegen tables the monomorphizer does not carry, so a partial mirror would answer differently from the rewrite for those cases, replacing a shape where both consultors say "unknown" with one where they disagree. Fix direction: lift the element-type derivation into one helper both consultors call, rather than a second partial copy that can drift from the first — the shape #1286 argues for throughout. | [#1327](https://github.com/aallan/vera/issues/1327) | | A match binding's GC shadow-stack push is never popped, so the shadow stack grows with recursion depth until it traps. Both `_translate_match`'s scrutinee rooting and `_setup_match_arm_env`'s binding rooting push onto `$gc_sp` and leave it advanced for the rest of the body; a function restores `$gc_sp` only on the way out, so a recursive function whose body matches pushes once per live frame and eventually crosses `$gc_stack_limit`, whose overflow check is a bare `unreachable`. Bisected on a `decreases`-guarded recursion carrying one match: an ADT (i32) scrutinee survives depth 2046 and traps at **2047**, **identically before and after [#1305](https://github.com/aallan/vera/issues/1305)**, which is what makes this pre-existing rather than new — the i32 rooting has behaved this way since #705/#707. What #1305 changed is reach: a `String` / `Array` scrutinee now takes this path too, and because the pair form roots twice per frame (scrutinee pointer and binder pointer) it survives only 1364 and traps at **1365** — two thirds of the ADT depth, which is what the slot arithmetic predicts: an ADT frame roots the parameter and the binder, a pair frame roots the parameter, the scrutinee pointer and the binder pointer, so three slots per frame against two and 2047 x 2/3 = 1364.7. Loud in every case — `unreachable`, never a wrong value — but the trap names no source construct and the depth at which it arrives is an artefact of how many pushes a body happens to make. Fix direction: pop what a match arm pushes at the arm's end (or scope the rooting to the arm body), so the depth a program can recurse to stops depending on how many matches its body contains. | [#1322](https://github.com/aallan/vera/issues/1322) | diff --git a/README.md b/README.md index 212ef104..1277a6b7 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ cp /path/to/vera/SKILL.md ~/.claude/skills/vera-language/SKILL.md ## Project status -Vera is in **active development** at v0.1.12: 2,000+ commits, 210 releases, 11,953 tests, 95% Python code coverage, 244 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. +Vera is in **active development** at v0.1.12: 2,000+ commits, 210 releases, 11,960 tests, 95% Python code coverage, 244 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. The reference compiler — parser, AST, type checker, contract verifier (Z3), WASM code generator, module system, browser runtime, and runtime contract insertion — is working. The language specification is in draft across [14 chapters](spec/). diff --git a/ROADMAP.md b/ROADMAP.md index 3109512b..a5cc2e52 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,16 +12,17 @@ Ordering derives from the design principles ([DESIGN.md](DESIGN.md)): verificati ## Where we are -11,953 tests, 244 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. +11,960 tests, 244 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. ## The v0.1.13 burndown -*Fourteen open bugs, driven to zero.* +*Fifteen open bugs, driven to zero.* A bug class outranks stage work, so the next release takes the open `bug`-labelled set as its queue. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) carries each row's full account and stays the one place the detail lives; this table is the order of attack. | Issue | What | |---|---| +| [#1331](https://github.com/aallan/vera/issues/1331) | A user `data` declaration named after a built-in container compiles at the container's width, so a check-green program loses its exports to an E602 refusal. | | [#1315](https://github.com/aallan/vera/issues/1315) | The checker accepts a constructor pattern over a container ADT that has no constructors. | | [#1320](https://github.com/aallan/vera/issues/1320) | The checker admits literal patterns over scrutinees that can never match them — the general disease behind #1315. | | [#1316](https://github.com/aallan/vera/issues/1316) | A main-file `type` alias named after a prelude ADT leaks into the prelude's own bodies. | diff --git a/TESTING.md b/TESTING.md index 854c16f0..f6c1b6cd 100644 --- a/TESTING.md +++ b/TESTING.md @@ -6,7 +6,7 @@ This is the single source of truth for Vera's testing infrastructure, coverage d | Metric | Value | |--------|-------| -| **Tests** | 11,953 across 175 files (~155,000 lines of test code; 11,754 passed + 26 stress-deselected, 173 skipped) | +| **Tests** | 11,960 across 175 files (~155,000 lines of test code; 11,761 passed + 26 stress-deselected, 173 skipped) | | **Compiler code coverage** | 95% Python, 87% JavaScript (CI minimum: 80%) | | **Conformance programs** | 244 programs across 9 spec chapters, validating every language feature | | **Example programs** | 42, all validated through `vera check` + `vera verify` | @@ -222,8 +222,8 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_builtin_typevar_collision_970.py` | 61 | 811 | #970 a user `forall` type-var name colliding with a built-in generic's internal name (`T`/`E`/`A`/`B`/`K`/`U`/`V`): focused check/verify pins for the compound-argument shapes (`@Array>`, `@Result>`, `@Map>`) plus a collide-vs-control differential battery over every generic-builtin family and contract/where-helper position. Also pins marker-strip (the `#b` namespacing marker must never reach an E205/E202 diagnostic), a registry-consistency pin (every built-in ability-constraint `type_var` stays a member of its `forall_vars`), the dual completeness-gap pinned in both argument orders, a tier-split equality pin, and the #1069 leaked-placeholder message-rendering sweep (a stripped built-in var renders as `?`, not a bare letter, at every reachable actual-type slot: the mismatch sites plus the operator/index/interpolation family, `assert`/`assume`, `if` condition and branches, and the contract/refinement predicates — one parametrized row per converted render slot, with the provably-unreachable sites documented in the class docstring) | | `test_check_changelog_updated.py` | 68 | 712 | `check_changelog_updated.py` unit + end-to-end tests: file classification (incl. file-style exact-match vs directory-style prefix-match), CHANGELOG diff parsing with `[Unreleased]` section tracking, bare-heading rejection, and full-file context (regression test for bullets far below the heading), `Skip-changelog:` trailer detection, temp-repo integration covering substantive/exempt/label/trailer paths, and `GIT_*`-env hermeticity of the temp-repo fixtures (regression for the pre-commit-hook env leak) | | `test_release.py` | 62 | 798 | Release policy and registry verification (#481): strict project-name and version parsing/comparison, version-bump/TestPyPI/recovery planning, exact confirmation and immutable-tag guards, first-parent version-introduction discovery, package-change recovery refusal, non-empty CHANGELOG extraction, one-wheel/one-sdist SHA-256 manifests, malformed registry-response handling, missing/filename/hash propagation retries, exact filename/hash verification, and CLI dispatch/GitHub-output wiring. An autouse fixture scrubs hook-exported `GIT_*` variables so the tmp-repo git calls (fixture helpers and `release.py`'s own) never resolve to the developer's repository when the suite runs inside a pre-commit hook. | -| `test_check_doc_counts.py` | 89 | 1,002 | `check_doc_counts.py`'s pure per-document checks: KNOWN_ISSUES refactoring line counts (±10% tolerance band incl. the exact-boundary case, drift detection, empty-file citation, hyphenated paths, missing file/section/rows, the #419 empty-section sentinel + its cannot-mask-a-malformed-table dual), HISTORY version-row format (issue-link limit, ` — ` separator rejection, dateless-row and prose exemption, line-number reporting), the TESTING.md tests breakdown (parts summing to the collected total, a self-consistent-but-stale row, and the reworded-row error), and vera/README.md's Test Suite counts (all four checked independently — mutation-validated by dropping each citation in turn — plus the reworded-paragraph error, a thousands-separator case pinning that every one of the four counts is read comma-tolerantly, and the two section-anchoring cases — a reworded paragraph with decoy counts in a later section, and a renamed heading, both of which must fail loud rather than match across the section boundary). The reworded case is a test in its own right for both new checks: a pattern that matches nothing must be an error, or rewording the sentence silently switches the gate off. Also the release count (README's status line and HISTORY's total against each other and against `git tag`: the matching case, the one-ahead release cut that `release.yml` has not tagged yet, that +1 being the ONLY slack once the version is tagged, the two-behind drift that actually shipped, per-document reporting, and a tagless checkout standing the oracle down without standing down the cross-check), plus the tag reader itself against real repositories built in `tmp_path` — release tags read, `nightly`/`-rc1` not counted as releases, and both no-evidence answers (`None` rather than `[]`, since an empty list would read as zero releases and make every documented count wrong) for a tagless checkout and a directory that is not a repository at all. CONTRIBUTING.md's pre-commit hook count is checked the same way, reworded-sentence case included; and the CI-pipeline lint row against `ci.yml`'s lint job — a matching row, a step present in CI but absent from the row (the drift that shipped), a row entry CI no longer runs, the same set in a different order, a reworded row and a renamed job (both errors, not skips), that only the lint job is read rather than the whole workflow, and the shipped pair both clean and red with one entry dropped | -| `test_grammar_alignment.py` | 89 | 713 | `check_grammar_alignment.py` gate ([#683](https://github.com/aallan/vera/issues/683)): both extractors (Lark headers with the `?`/`!`/`_` markers stripped, template parameters and rule priorities tolerated, and `-> alias` names deliberately not collected; spec headers from ```ebnf fences only), the allowlist arithmetic in all three directions — unwaived drift, a spent entry both files now have, and an entry whose premise broke — one case per waiver proving the fact it rests on is actually checked, the name-deleted-from-both-files case that must not read as agreement, a mutation restoring the spec's old `assert_stmt` name, non-vacuous extraction, and the false positive the issue itself rested on: `qualified_call` and `module_call` are spec headers Lark expresses as aliases, and must never be reported as drift. Three pin the premise checks against ways they used to pass vacuously: an alias surviving only inside a `//` comment must not hold its waiver up, an alias that moved to another rule must fail the waiver naming `fn_call`, and a spent waiver whose premise also broke must yield one instruction rather than two opposite ones | +| `test_check_doc_counts.py` | 94 | 1,051 | `check_doc_counts.py`'s pure per-document checks: KNOWN_ISSUES refactoring line counts (±10% tolerance band incl. the exact-boundary case, drift detection, empty-file citation, hyphenated paths, missing file/section/rows, the #419 empty-section sentinel + its cannot-mask-a-malformed-table dual), HISTORY version-row format (issue-link limit, ` — ` separator rejection, dateless-row and prose exemption, line-number reporting), the TESTING.md tests breakdown (parts summing to the collected total, a self-consistent-but-stale row, and the reworded-row error), and vera/README.md's Test Suite counts (all four checked independently — mutation-validated by dropping each citation in turn — plus the reworded-paragraph error, a thousands-separator case pinning that every one of the four counts is read comma-tolerantly, and the two section-anchoring cases — a reworded paragraph with decoy counts in a later section, and a renamed heading, both of which must fail loud rather than match across the section boundary). The reworded case is a test in its own right for both new checks: a pattern that matches nothing must be an error, or rewording the sentence silently switches the gate off. Also the release count (README's status line and HISTORY's total against each other and against `git tag`: the matching case, the one-ahead release cut that `release.yml` has not tagged yet, that +1 being the ONLY slack once the version is tagged, the two-behind drift that actually shipped, per-document reporting, and a tagless checkout standing the oracle down without standing down the cross-check), plus the tag reader itself against real repositories built in `tmp_path` — release tags read, `nightly`/`-rc1` not counted as releases, and both no-evidence answers (`None` rather than `[]`, since an empty list would read as zero releases and make every documented count wrong) for a tagless checkout and a directory that is not a repository at all. CONTRIBUTING.md's pre-commit hook count is checked the same way, reworded-sentence case included; and the CI-pipeline lint row against `ci.yml`'s lint job — a matching row, a step present in CI but absent from the row (the drift that shipped), a row entry CI no longer runs, the same set in a different order, a reworded row and a renamed job (both errors, not skips), that only the lint job is read rather than the whole workflow, and the shipped pair both clean and red with one entry dropped | +| `test_grammar_alignment.py` | 91 | 753 | `check_grammar_alignment.py` gate ([#683](https://github.com/aallan/vera/issues/683)): both extractors (Lark headers with the `?`/`!`/`_` markers stripped, template parameters and rule priorities tolerated, and `-> alias` names deliberately not collected; spec headers from ```ebnf fences only), the allowlist arithmetic in all three directions — unwaived drift, a spent entry both files now have, and an entry whose premise broke — one case per waiver proving the fact it rests on is actually checked, the name-deleted-from-both-files case that must not read as agreement, a mutation restoring the spec's old `assert_stmt` name, non-vacuous extraction, and the false positive the issue itself rested on: `qualified_call` and `module_call` are spec headers Lark expresses as aliases, and must never be reported as drift. Three pin the premise checks against ways they used to pass vacuously: an alias surviving only inside a `//` comment must not hold its waiver up, an alias that moved to another rule must fail the waiver naming `fn_call`, and a spent waiver whose premise also broke must yield one instruction rather than two opposite ones | | `test_check_examples_run.py` | 79 | 1,315 | `check_examples_run.py`, the harness gate that RUNS the examples. Five separable parts, each in both directions. **The coverage rule** — the shipped tables cover the shipped corpus exactly, and an unclassified example, a stale `RUN_SPECS` or `SKIPS` key whose file is gone, a name in both tables, and a skip citing an undocumented property are each an error; the empty corpus is an error too, since a glob that stops matching would otherwise report success over zero programs. Plus the specs' own well-formedness: every named entry point is `public` in its example, every no-main example pins one (or `vera run` would fall back to an arbitrary first export), and no skip property is unused. **The runner** — a seeded `tmp_path` corpus proves it goes red on a program that type-checks and compiles but traps at run time, green on one that does not, and reports only the broken member of a mixed pair; a fixture whose first export is clean and whose named one traps proves `spec.fn` is actually honoured rather than ignored; a writer program proves each run gets a scratch working directory, so a gate run leaves nothing beside the examples. **The TESTING.md cross-check** — missing row, extra row, rename (reported naming both sides), wrong disposition and wrong skip property are errors, the parse stops at the next heading so a row-shaped line in a later section is not swept in, and both a reworded heading and a heading whose table has vanished fail loud rather than finding nothing to compare. **The output signal** -- the second half of the two-signal discipline `check_examples.py` established: the fallback note that `vera run` prints when it cannot use the named entry point is a failure even at exit 0, an absent `expect` sentinel is a failure even at exit 0, and a spec without one asserts nothing about output; end to end, a privatised `main` and a program that completed down a graceful arm each go red, and the same program passes once its own output is the sentinel, so the check reads the output rather than always failing. Which specs must carry a sentinel is **derived** from what each example declares — a resource effect in a function's effect row, or a call to a resource operation, both validated against the live effect registry so a renamed effect or op fails loudly — and asserted equal to the specs that have one, in both directions; the previous hard-coded triple could not see a fourth such example arriving without one. The runner's use of BOTH streams is pinned structurally -- `vera run` writes the note to stderr and nothing there on a clean exit, so no fixture can distinguish reading both streams from reading stdout alone, and a tripwire wired to the wrong stream is no tripwire. Also the hermetic-environment property: an ambient `VERA_DB_URL` or provider key is stripped so a gate run cannot be pointed at a real database or turned into a billed API call, a fixture spec puts its own URL back, and every neutralised name is checked to be one `vera/runtime/` actually reads | | `test_check_corpus_differential.py` | 52 | 895 | `check_corpus_differential.py`, the burndown instrument that compiles the corpus at two revisions. The pure pieces only — the real two-revision run costs minutes and is not a test. **Classification**, all four verdicts: identical, WAT differs, and each one-sided compile failure as its own kind, since a compilability reversal reported as a text difference is the mis-description the instrument exists to avoid; failing at both revisions is not a mover and is counted separately, so a green run states how much of it was vacuous. **Enumeration** — recursive, keyed by repo-relative POSIX path, and an empty corpus is an error rather than a clean run over nothing. **The canary** — each side must import the compiler it was pointed at, so a side silently resolving to the venv's editable install cannot compare a revision against itself; an import failure and a foreign compiler are different messages. **Reporting** — every mover named with its reason, the exit code, the `--json` shape, and a program missing from one side reported rather than dropped. One test asserts the instrument is absent from `.pre-commit-config.yaml`, so the docstring's claim cannot rot | | `test_check_editor_grammars.py` | 20 | 249 | `check_editor_grammars.py` gate ([#1156](https://github.com/aallan/vera/issues/1156)): the registry read (every effect in, every ability out, and the four names the grammars actually drifted on present so the set checked is non-vacuous), the word-boundary presence test across all three grammar formats (JSON, plist XML, Vim keyword list) including the prefix pair `Http`/`HttpServer` in both directions, the deliberate comment-mention false pass, a metacharacter pair that only passes when the name is matched literally (`A.B` present, `A0B` not), and the empty registry; the gate's primary path end to end — a listed grammar with an effect stripped out, and a listed README with one stripped out of its prose bullet, each red against an otherwise-clean mirrored tree; the completeness guard — a grammar discovered under `editors/` but absent from `GRAMMARS` fails that same tree, over three discovery routes (`.el` outside a syntax directory, a tree-sitter `.scm` query set, a `.tmLanguage.json` filed anywhere but `syntaxes/`); and the registry's provenance, run as a subprocess against a throwaway checkout whose `vera` package names an effect the grammars do not, which is the only way to see that the list comes from the tree being checked rather than from site-packages. The shipped grammars and READMEs are currently clean | diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 8802ce1c..8b1127f7 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -3265,7 +3265,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 11,953 tests, including a 244-program conformance suite +- 11,960 tests, including a 244-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/scripts/check_doc_counts.py b/scripts/check_doc_counts.py index 61fd41a0..bf1388da 100644 --- a/scripts/check_doc_counts.py +++ b/scripts/check_doc_counts.py @@ -1166,6 +1166,49 @@ def open_bug_issues(repo: str = "aallan/vera") -> list[int]: return numbers +_ERROR_CODES_CITATION = re.compile( + r"maps every code to a short description \((\d+) entries — (\d+) `E` codes " + r"and the two `W` warning codes\)" +) + + +def check_error_codes_count(readme_text: str, registry: dict[str, object]) -> list[str]: + """Check vera/README.md's `ERROR_CODES` figures against the registry. + + Three numbers in one sentence, and none was gated: the total, the `E` + count, and the claim that the remainder is exactly the two `W` codes. + The registry is the only source for any of them, so the sentence could + drift on every code added (#1330 review). + """ + found = _ERROR_CODES_CITATION.search(readme_text) + if found is None: + return [ + "vera/README.md: could not find the ERROR_CODES count sentence " + "('maps every code to a short description (N entries — N `E` " + "codes and the two `W` warning codes)')" + ] + cited_total, cited_e = (int(g) for g in found.groups()) + live_e = sum(1 for code in registry if code.startswith("E")) + live_w = sum(1 for code in registry if code.startswith("W")) + errors: list[str] = [] + if cited_total != len(registry): + errors.append( + f"vera/README.md ERROR_CODES total: doc says {cited_total}, " + f"live is {len(registry)}" + ) + if cited_e != live_e: + errors.append( + f"vera/README.md ERROR_CODES E-code count: doc says {cited_e}, " + f"live is {live_e}" + ) + if live_w != 2: + errors.append( + f"vera/README.md says the remainder is two `W` codes; the " + f"registry has {live_w}" + ) + return errors + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( @@ -1776,6 +1819,9 @@ def check_testing(pattern: str, expected: int, label: str) -> None: # 19. Check the cited corpus-program count (#1160 review) # ------------------------------------------------------------------ + from vera.errors import ERROR_CODES + + errors.extend(check_error_codes_count(vera_readme_md, ERROR_CODES)) errors.extend(check_corpus_count(root)) errors.extend(check_conformance_skip_total(root)) diff --git a/tests/test_check_doc_counts.py b/tests/test_check_doc_counts.py index abc38f42..744883b2 100644 --- a/tests/test_check_doc_counts.py +++ b/tests/test_check_doc_counts.py @@ -1000,3 +1000,52 @@ def test_the_parity_check_is_not_wired_into_the_default_run(self) -> None: for index in calls: guarded = source[max(0, index - 600) : index] assert "args.check_bug_issues" in guarded + + +class TestErrorCodesCount: + """vera/README.md's `ERROR_CODES` figures, gated (#1330 review). + + Three numbers in one sentence and none was read by the oracle, so the + registry could grow a code on any PR and the sentence would drift + silently. + """ + + _SENTENCE = ( + "The `ERROR_CODES` dict in `errors.py` maps every code to a short " + "description (160 entries — 158 `E` codes and the two `W` warning " + "codes)." + ) + + def _registry(self, e: int = 158, w: int = 2) -> dict[str, object]: + codes: dict[str, object] = {f"E{n:03d}": "x" for n in range(e)} + codes.update({f"W{n:03d}": "x" for n in range(w)}) + return codes + + def test_the_shipped_sentence_matches_the_live_registry(self) -> None: + from vera.errors import ERROR_CODES + + text = (Path(__file__).parent.parent / "vera/README.md").read_text( + encoding="utf-8" + ) + assert _MOD.check_error_codes_count(text, ERROR_CODES) == [] + + def test_a_stale_total_is_caught(self) -> None: + errors = _MOD.check_error_codes_count(self._SENTENCE, self._registry(159, 2)) + assert [e for e in errors if "total" in e] + + def test_a_stale_e_count_is_caught(self) -> None: + errors = _MOD.check_error_codes_count( + self._SENTENCE.replace("158 `E`", "157 `E`"), self._registry() + ) + assert [e for e in errors if "E-code count" in e] + + def test_a_third_warning_code_is_caught(self) -> None: + """The sentence says "the two `W` warning codes" in prose, so the + only way it can go wrong is the registry gaining a third.""" + errors = _MOD.check_error_codes_count(self._SENTENCE, self._registry(158, 3)) + assert [e for e in errors if "two `W` codes" in e] + + def test_a_reworded_sentence_is_an_error_not_a_skip(self) -> None: + text = "The ERROR_CODES dict has 160 entries." + errors = _MOD.check_error_codes_count(text, self._registry()) + assert len(errors) == 1 and "could not find" in errors[0] diff --git a/tests/test_grammar_alignment.py b/tests/test_grammar_alignment.py index 008c6009..ded0761d 100644 --- a/tests/test_grammar_alignment.py +++ b/tests/test_grammar_alignment.py @@ -711,3 +711,43 @@ def test_both_halves_read_the_same_blanked_text(self) -> None: def test_an_escaped_quote_inside_a_literal_does_not_end_it(self) -> None: rules, _t = _MOD._symbols(r'str_lit: "\"" body "\""', {"str_lit", "body"}) assert rules == {"str_lit", "body"} + + +class TestUncomparedProductionShape: + """The gate's documented boundary, measured (#1330 review). + + `body_drift` compares the symbols a right-hand side refers to, not + its alternation, grouping or repetition — stated at the top of + `check_grammar_alignment.py` and in TESTING.md's gate row. A review + reported the consequence as a defect: an empty alternative added to + `effect_list` references nothing new, so the gate stays silent. + + That is true, and it is the boundary rather than a regression — but + it was only ever claimed. This pins it, so the day someone extends + the comparison to shape, this cell fails and the two documents get + updated with it instead of keeping a stale limitation. + """ + + def _with_empty_alternative(self) -> list[str]: + out: list[str] = [] + for line in _spec_lines(): + out.append(line) + if line.startswith("effect_list:"): + out.append(" |") + return out + + def test_an_empty_alternative_is_not_seen(self) -> None: + mutated = self._with_empty_alternative() + assert mutated != _spec_lines(), "the injection point is gone" + assert _MOD.body_drift(_lark_lines(), mutated) == [] + + def test_a_symbol_level_change_to_the_same_rule_still_is(self) -> None: + """The control: the gate is silent about SHAPE, not about + `effect_list` — a symbol added to the same production is caught.""" + mutated = [ + line + "\n | UPPER_IDENT" if line.startswith("effect_list:") + else line + for line in _spec_lines() + ] + assert [p for p in _MOD.body_drift(_lark_lines(), mutated) + if p.startswith("effect_list:")] diff --git a/vera/README.md b/vera/README.md index f52bbaf2..5ab294b7 100644 --- a/vera/README.md +++ b/vera/README.md @@ -753,7 +753,7 @@ The `ERROR_CODES` dict in `errors.py` maps every code to a short description (16 ## Test Suite -Testing spans a **pytest suite** of 11,953 tests across 175 files — compiler-internals unit tests plus a **conformance suite** (244 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. +Testing spans a **pytest suite** of 11,960 tests across 175 files — compiler-internals unit tests plus a **conformance suite** (244 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. See **[TESTING.md](../TESTING.md)** for the comprehensive testing reference -- test file table, conformance suite details, compiler code coverage, language feature coverage, helper conventions, validation scripts, CI pipeline, and guidelines for adding tests. From d6d37f47d9451de6a53c90796e97385df062732b Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Sat, 15 Aug 2026 07:11:21 +0100 Subject: [PATCH 66/69] Close the release PR's last eleven review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten fixed, one refuted-and-replaced, each decided by measurement. Spec and docs: * **§6.4.3 named two unguarded @Nat narrowing residuals; there are three.** Probed: `Tuple(float_to_int(x), 5)` narrowing into a `@Tuple` records `nat_bind`/`tier3_unguarded` and an E504 whose own text names the site ("tuple component"), and the emitted function carries no guard — in return position, at a call argument, and under a `let` alike. The only guard is the one the CONSUMER emits when it destructures, so a tuple that is only returned or passed on is never checked. `KNOWN_ISSUES.md`'s #754 row said that component was "runtime-guarded at the function boundary", which the same probe refutes (the concrete CONSTRUCTOR field is guarded; the tuple component is not), so the row moves from three unguarded sites to four. * **§9's "outside that" names its property.** The two code-span shapes are outside the ROUND-TRIP property, not the fixed point above — which the preceding paragraph declares exception-free. * **`_collect_namespace_fn_names`'s docstring no longer claims the ambiguity half is prelude-independent.** `namespace_fn_names` (vera/monomorphize.py) records the measurement that it is not: the combinators are overridable, so two dependencies exporting `option_map` are ambiguous under the empty prelude and not under the populated one. The E608 rail reads the first, prelude-empty answer, so the two-call ordering is load-bearing — which the old wording invited a reader to undo. Comment only; behaviour unchanged. Tests, each strengthening validated by breaking what it guards: * **W002's row is asserted exactly, not by membership** — the proposed `"Http" not in description` is untrue (every W002 names the commutative set as `(Http)`) and could not discriminate anyway, since `Http` is subtracted before the message is built. The exactness form catches the union it was aimed at: mutating the walk to add `State` yields "performs IO, State" — red under exactness, green under membership. * **The E153 fix suggestion is read from the clause that makes it.** The old sweep also collected the `{name}_value` example and `handle`, both boilerplate, so a deleted table entry stayed green; deleting the `data` and `where` hints now fails exactly those two cells. * **The lowercase-constructor control completes the vacuity argument** (`Holder { helper(Int) }` is a ParseError; `Holder { Helper(Int) }` parses, so the rail under test really is the case rail). * **The #1309 width battery compares the whole `$twice` body**, not its header — measured byte-identical to the fresh-name control in all 70 cells, and strictly stronger (a body-only divergence passes a header comparison). * **#1268's `tier3_runtime == 1` names its obligation**: the count is the `float_to_int_domain` one, the `tier3_unguarded` bind having discharged to no tier. * **#1281's diamond fixture threads the checker's artifacts into codegen**, as `cmd_compile` does; the two registries are identical either way, so the cells measure what they did before. * **The emitted-`put` assertion uses `wat_fn_names` exact membership.** * **The prelude-ADT alone cells pin §8.4.1's injection split.** The acceptance battery accepts either answer per cell by design; a rail keyed on demand would therefore keep the whole suite green, which a patched `_contends_with_prelude` confirms — only the new cell fails. Co-Authored-By: Claude --- CHANGELOG.md | 2 + FAQ.md | 2 +- KNOWN_ISSUES.md | 2 +- README.md | 2 +- ROADMAP.md | 2 +- TESTING.md | 16 +++--- docs/llms-full.txt | 2 +- spec/06-contracts.md | 2 +- spec/09-standard-library.md | 2 +- tests/test_checker_effects.py | 12 ++++- tests/test_checker_modules.py | 50 +++++++++++++----- .../test_codegen_alias_adt_name_width_1309.py | 11 ++-- tests/test_exn_throw_payload_1268.py | 7 +++ tests/test_module_generic_collision_1281.py | 16 +++++- .../test_nat_narrowing_return_differential.py | 10 ++-- tests/test_prelude_adt_namespace_1277.py | 51 +++++++++++++++++++ vera/README.md | 2 +- vera/codegen/modules.py | 12 +++-- 18 files changed, 163 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ff54831..808e690a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Two imports supplying one bare name are refused, in every namespace** ([#1304](https://github.com/aallan/vera/issues/1304)). Spec §8.5 ordered a local declaration against an import (§8.5.2) and gave the module-qualified form for reaching what a clash hides (§8.5.3), but defined no order between two *imports* that both supply one name. Neither did the implementation, and the gap was reachable: a module importing two dependencies that each export `forall fn gen` — one returning `@Int`, one `@Bool` — bound its bare call to whichever supplier a set of module paths happened to yield first, so one unchanged file was `vera check`-green on one run and `[E121] body has type Bool` on the next. Measured at the branch point across eight consecutive runs and eight hash seeds: accepted on seeds 0, 2 and 3, rejected on 1, 4, 5, 6 and 7, with the winner tracking module-name hash order rather than which import is written first. Codegen's E608 rail caught the *entry-visible* pair before it could matter there; the flap lived in the shapes the rail only reached at compile, from inside a module the entry program merely imports. Spec §8.5.2.2 now states the rule — a program **MUST NOT** leave a namespace with two imports supplying one bare function name — and the checker enforces it as **E155**, a check-phase code for a scope question that had been enforced by a codegen rail at the wrong layer. Refusing is what removes the flap rather than merely labelling it: with no pick to make, there is no iteration order left to expose, which a deterministic first-wins order would not have achieved (it would make the resolved declaration implicit in import sequence, §0.2.2, and let a library *adding* an export silently rebind a downstream bare call). The refusal is **definition-gated**, matching the rail it generalises: it fires because the import pair exists, not because a body names it, so an entry program importing two suppliers and never calling either is refused exactly as E608 already refused it, and rewriting a bare call in module-qualified form does not lift it. Two shapes clear it, both exercised through to their runtime value: a **local declaration** of the name (§8.5.2 — every bare call is then the local one, and each import stays reachable through `dep::name(...)`), or a **selective import** narrowing the other module's list. The ambiguity predicate is the one `namespace_fn_names` already derived for #1281 and #1299, now exposed per namespace as well as unioned, so the layer that refuses early and the layer that backstops it cannot disagree about which shape is ambiguous; the E608 condition keeps its cell, driven through a door that bypasses the checker. An ambiguous name is not injected into the type environment at all — reporting the clash while binding one supplier would leave the follow-on diagnostics keyed to whichever module the injection loop reached first, which is the nondeterminism the refusal exists to remove — so a bare call to it misses with an ordinary `E200` instead — an E-coded diagnostic emitted at **warning** severity, which the `--json` envelope reports in `warnings` rather than `diagnostics` and which does not fail the check on its own (measured: a program whose only diagnostic is `E200` reports `ok: true` and exits 0). The W-series is the separate `W001`/`W002` code namespace, and this is not one of them. **The data namespaces flapped the same way and are folded in.** Spec §8.5.4 gives constructor names the same shadowing rules as function names, which a function-only refusal would have made false: two modules each exporting a `public data Shape` with different constructor field types type-checked on some hash seeds and reported `[E213]` on others (accepted on seeds 2, 8, 9, 10 and 11; rejected on 0, 1, 3, 4, 5, 6 and 7), and the accepting seeds were the worse half — `check` **and** `verify` both passed, and the program died at `run` with an `E609` located at line 0 of the entry file, naming two modules the entry never imported. Data types are now **E156** and constructors **E157**, one code per declaration namespace exactly as codegen splits E608/E609/E610, and reported independently because they come apart: two modules exporting differently-named types that share a constructor name clash on the constructor alone. Their remedy differs from the function one and says so — E609/E610 refuse two modules' same-named data declarations by DECLARATION, consulting neither visibility nor the importer's filter nor local shadowing (the relaxation E608 received in [#1281](https://github.com/aallan/vera/issues/1281) has no data-side twin), so narrowing an import or shadowing the name locally leaves the program `E609` at compile. Both were measured against the fixture and both fail — as does marking one declaration `private` — so the two diagnostics prescribe renaming, and a cell pins that measurement so the fix text cannot drift into offering remedies that do not work. That rail over-breadth is now tracked as [#1317](https://github.com/aallan/vera/issues/1317). **A name the built-in registry already owns is not a clash** — the injection loops are `setdefault` over a `TypeEnv` the built-ins populate first, so a dependency exporting its own `option_map` never wins the bare name (measured as `E201` against the *prelude's* two-argument signature). The first cut of this refusal did not pass the built-in snapshot and reported two such dependencies as a clash, which was a new rejection rather than an earlier one; `namespace_fn_names`' claim that its ambiguity half is identical with or without the prelude argument was wrong for the same reason and is corrected, with the codegen call ordering it depends on now pinned by a cell. - **A `throw` payload is runtime-guarded, not only obligated** ([#1268](https://github.com/aallan/vera/issues/1268)). `throw(v)` narrows `v` into the `Exn` payload, and since the static half of this issue the narrowing carries the same obligation every other binding site does — but codegen emitted no guard, so the obligation's Tier-3 leg promised a runtime check that did not exist and an unverified `vera compile`/`run` delivered the violating value anyway. `throw(0 - 5)` under `effects(>)` ran to completion and returned **-5** through the `@Nat` payload; the refined spelling (`type Pos = { @Int | @Int.0 > 0 }`) did the same. Worse than a wrong answer: a handler clause binds the payload at its declared type, so the verifier hands every downstream consumer the invariant the payload just broke — a `@Nat`-taking function discharging `ensures(@Bool.result)` at Tier 1 from its parameter's type alone reported a **postcondition violation at run time on a postcondition `vera verify` had proved**. `throw` now takes the write boundary's guards at its op-call site, beside `put`'s ([#1203](https://github.com/aallan/vera/issues/1203)): the `@Int` -> `@Nat` sign guard, the `@Nat` -> `@Int` widening guard, and — refined FIRST, as at every other narrowing site — the §2.6.5 predicate guard for a refined payload, which traps through `$vera.contract_fail` naming the predicate that failed (`Refinement violation in throw(@Pos) / payload: @Int.0 > 0 failed`). The three arms mirror the verifier's own obligation triple one-for-one, so the obligation stream and the emitted guards stay in lock-step: the payload obligation is now `guarded` at all three arms and its Tier-3 leg is counted in `tier3_runtime` rather than disclosed as `tier3_unguarded`, and the refined arm's `guarded` claim is intersected with the same `_refined_boundary_codegen_guardable` test every other refined site uses, so an erased `@Unit` base or a nested refinement — which codegen emits no guard for — stays honestly unguarded. That mirror needed one repair to be true: it answered "guarded" for a refinement OVER a refinement, which `_refinement_guard_parts` refuses outright with a loud `E618` because the outer predicate alone would silently drop the inner membership — so `vera verify` exited 0 recording a Tier-3 runtime check for a program `vera compile` then refuses, a promise about a run that can never happen. It now bails on a refinement base, and the obligation discloses `tier3_unguarded` while `E618` still refuses. The same audit found the **qualified spelling recording something different from the bare one**: the `QualifiedCall` arm hardcoded `guarded=False` behind a comment stale since [#1203](https://github.com/aallan/vera/issues/1203), so `Exn.throw(v)` — which codegen lowers by synthesizing a bare node and delegating to the very dispatcher that emits the guards — disclosed `E504`/`E506` for a boundary that traps, and `State.put(v)` had been doing the same since #1203. Both now take the bare arm's rule on the same key (`op.parent_effect`), so the two spellings of one operation record identical statuses. The review of that fix found the arm had been hand-written as a refined-then-`@Nat` chain with **no widening branch at all**, so `State.put(@Nat.0)` / `Exn.throw(@Nat.0)` into an `@Int` cell recorded no obligation whatever while codegen emitted the `@Nat` -> `@Int` widening guard on both spellings — a guard the obligation stream never mentioned, the mirror image of the claim-without-a-guard this issue started from. It now routes through the shared `_obligate_binding_triple`, so the three arms cannot drift apart again by omission. The triple itself then turned out to be missing the [#820](https://github.com/aallan/vera/issues/820) INTERSECTION at these boundaries: its three arms are an `elif` chain, so a refinement OVER `@Int` claimed the value and the widening check never ran — and codegen mirrored that exactly, so both sides agreed to skip a check the UNREFINED spelling performs. A refinement predicate does not imply fit-in-i64, and `{ @Int | true }` is satisfied by the negative a `@Nat` above i64.MAX reinterprets to, so adding a refinement WEAKENED the boundary: `Exn` fed u64.MAX trapped on the widening guard while `Exn<{ @Int | true }>` fed the same value returned **-1**. The widening obligation and its guard now ride beside the refined pair rather than being replaced by it, and the two spellings trap alike; a user-declared effect's operation and `IO.sleep`'s `@Nat` formal stay the honest [#754](https://github.com/aallan/vera/issues/754) unguarded class. Two diagnostic rationales (`E504`, `E531`) that listed the `throw` payload among the unguarded sites — false once the guard landed, and contradicting the spec sentences this change amends — no longer do. Reaching the predicate needed the payload's TYPE, which neither of a cell's two names carries: `family` renders the predicate and `base` strips it, so `CellNames` now carries the type expression its producer already held rather than parsing one back out of a mangled family name. The predicate lowering itself is injected into the translation context (`set_refinement_guard_emitter`), because the two halves of a §2.6.5 guard sit on opposite sides of that seam — which local at what width is the context's question, while the trap message, the contract-fail import and the E617/E618 diagnostics are the generator's. An unrefined payload's WAT is byte-identical to before: a differential over all 278 pre-existing corpus programs — every `examples/` and `tests/conformance/` program, compiled and verified on both trees — moves nothing, in emitted WAT or in the obligation and diagnostic streams. +- **Spec §6.4.3 and `KNOWN_ISSUES.md` now name every unguarded `@Nat` narrowing site** (release-PR review). §6.4.3 said two sites stay unguarded — a user-declared effect operation's argument and the generic-instantiated constructor field — and the `#754` row said the value-position tuple component was "runtime-guarded at the function boundary". Measured, a **tuple component at construction** is a third: `Tuple(float_to_int(x), 5)` narrowing into a `@Tuple` records `tier3_unguarded` with an E504 that names the site (`@Int value narrowing into a @Nat tuple component`), and the emitted function carries no guard — at construction, in return position, or at a call argument alike. The only guard is the one the *consumer* emits when it destructures, so a tuple that is only returned or passed on is never checked. Both documents now say so; the behaviour is unchanged and the residual stays disclosed statically. + - **`json_parse` accepts one domain, and both runtimes accept it** ([#1306](https://github.com/aallan/vera/issues/1306)). The reference host parsed with `json.loads`, whose default `parse_constant` admits `NaN`, `Infinity` and `-Infinity`; the browser gated with `JSON.parse`, which refuses them as RFC 8259 requires. So the two hosts disagreed about *which call* rejects a non-finite value: the browser at the parse with a handled `Err`, the reference host at `json_stringify` — and there as a raw Python traceback rather than a Vera error ([#1302](https://github.com/aallan/vera/issues/1302) below). Spec §9.7.1 now states the accepted domain instead of leaving each host to inherit its parser's: RFC 8259-valid text that decodes to finite numbers and strings of Unicode scalar values, everything else `Err` at the parse with the same message on every runtime. **A non-finite number has two entry routes and the domain closes both.** The constants are one; the other is a syntactically valid number that overflows — `1e999`, `-1e999`, `[1e999]`, `{"a":1e309}` — which both host parsers accept, decoding to an infinite `JNumber` that then died at `json_stringify`, the same divergent-refusal-point defect one syntax over. RFC 8259 §6 sets no limit on a number's range and says an implementation may set one; Vera's is the finite `Float64` values, which is exactly what `json_stringify` can write back. **The refusal covers the integer spelling too**, and that half was reference-host-only: `json.loads` returns a Python `int` for a digit string with no fraction and no exponent, so `1` followed by 309 zeros never met a float range check — and then had to become an f64 at the WASM boundary, where `float()` raises. It died with `int too large to convert to float` where `JSON.parse`, which has no int/float split and sees an `Infinity` either way, returned the shared sentence. The integer bound is the double **rounding** boundary (`2**1024 - 2**970`) rather than `sys.float_info.max`, and compared in integer arithmetic: an integer *larger* than the largest finite double still rounds to it and both hosts accept it, so the obvious bound would have traded this divergence for its mirror image, and a bound implemented as `float(value)` would be the very overflow it is looking for. Underflow is not the same question and is not refused: `1e-999` decodes to `0`, finite and in the domain, pinned as a control beside `1e308` and the largest representable double so a refusal cannot generalise from "unrepresentable magnitude" to "large". Both value-level exclusions — overflow and lone surrogate — are found by ONE document-order walk returning the sentence itself, so "whichever comes first names the refusal" is the rule rather than a precedence table the two hosts could implement differently. The domain is the parse-side counterpart of the canonical output form [#1293](https://github.com/aallan/vera/issues/1293) pinned — a non-finite number has no JSON representation in either direction — and with no entry route through `json_parse`, the output-side refusal is now reachable only from a `JNumber` a program *constructed* from `nan()` or `infinity()`. The reference host's `parse_constant` hook **records rather than raises**, and the refusal is decided after the parse completes. Raising on sight would have made it answer a different question from the browser's: Python's scanner calls the hook the moment it sees the token, so `[Infinity_x]` — malformed for a reason that has nothing to do with the constant — would have reported the non-finite sentence natively while the browser reported a syntax error. Recording and continuing asks what the browser asks, by substituting `0` for each bare constant and re-parsing: *would this text be valid JSON if the constants were admitted?* Only then is the constant the whole story, and only then do both hosts say the same sentence; text malformed for any other reason keeps its host-native syntax message, as every syntax error always has. The browser's scan also only considers a token where a *value* may begin — the start of the text, or after `[`, `,` or `:`. Without that it found `NaN` at offset 1 of `-NaN`, substituted, re-parsed `-0` successfully and reported the shared sentence, where the reference host's parser never reaches the token at all and gives a syntax error. `-NaN`, `[-NaN]`, `+Infinity`, `infinity`, `nan`, `NaNx` and `-Infinityx` are all pinned as host-native on both hosts. The parity battery pins all four probe inputs from the issue's table plus the container and multi-constant shapes, compares the whole `Err` message across hosts rather than which arm was taken, and runs beside controls the refusal must not disturb — `"NaN"` as an ordinary string value among them. - **A lone-surrogate escape is refused at the parse, on both runtimes** ([#1308](https://github.com/aallan/vera/issues/1308)). `{"k":"a\ud800b"}` is grammatically legal RFC 8259 whose decoded value is not a sequence of Unicode scalar values, and a Vera `String` is — so the value has no UTF-8 encoding and cannot cross the WASM boundary at all. Both host parsers accepted the text and the *memory boundary* decided what happened next, differently and by accident: the browser's `TextEncoder` substituted U+FFFD, so `json_stringify` printed `{"k":"a�b"}` with nothing to tell the caller the value had changed, while the reference host died inside `_alloc_string` with a raw `UnicodeEncodeError`. Neither is a value the program can handle. The refusal now happens where the decoded value is known and before anything is marshalled, with one sentence naming the code point in canonical `\uXXXX` form so both escape casings produce the same message. Keys are covered as well as values, at any nesting depth — the key position is the one the issue's own reproduction used. The check does not overshoot: a *matched* high-then-low pair denotes one astral scalar value and still parses, which the batteries pin with matched pairs in every position, two pairs adjacent, and a pair at the end of a string. The two hosts' scans differ in a way worth recording, because the same rule reads differently against the two representations of a decoded value — `json.loads` has already combined a well-formed escape pair into one astral code point, so a plain D800–DFFF range test is complete on the reference host, while a JS string is UTF-16 and its scan must consume pairs before judging anything lone. With this and [#1306](https://github.com/aallan/vera/issues/1306), `md_parse` is the only operation on the shared surface still diverging (§12.9.3). diff --git a/FAQ.md b/FAQ.md index f74da008..654e6315 100644 --- a/FAQ.md +++ b/FAQ.md @@ -279,7 +279,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 11,960 tests, including a 244-program conformance suite +- 11,969 tests, including a 244-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 64b1b9ff..d172ed97 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -34,7 +34,7 @@ Things Vera cannot do yet, as distinct from defects in what it claims to do. | An ADT `decreases` measure is runtime-ranked only when its type's reachable field structure is fully concrete: a parameterized measure (`List`), or a concrete type whose recursion rides through a parameterized field, gets no runtime guard (never a wrong one — the registered generic layout does not describe concrete construction), staying statically checked and Tier-3-disclosed. Per-instantiation `$dec_size_` helpers (the `$eq_` pattern) close it. | [#1177](https://github.com/aallan/vera/issues/1177) | | Tier 2 verification (Z3 guided by `assert`/lemma hints) is specified in §6.3.2 but not implemented, so contracts that need hints fall to Tier 3 runtime checks. Per-monomorphization verification (#732) has since landed; full Tier 2 (hint-guided) stays on the Milestone 4 horizon. | [#427](https://github.com/aallan/vera/issues/427) | | `data invariant(...)` clauses (spec §2.6, §6.2.3) are not implemented — every documented form fails with E130 because the slot environment for the invariant predicate isn't wired up. Refinement types (`{ @T \| predicate }`, §2.6) are the working alternative until this lands. | [#686](https://github.com/aallan/vera/issues/686) | -| **Statically**, every narrowing *binding site* is obligated: a provably-negative value is an E503 error, and an untranslatable narrowing at an unguarded site is an E504 warning — so a program whose narrowings are all statically PROVEN stores no negative `@Nat` at these sites. `vera verify` exits 0 on the E504 warnings too, so verify-clean is the weaker property: a disclosed narrowing is one the static proof did not reach, and at an unguarded site nothing else does either. **At runtime** (an unverified `vera compile`/`run`), the guard covers every concrete *direct* binding site (a nested constructor sub-pattern is the exception, #765 below), generic function-formal calls, and the string/markup builtin `@Nat` parameters (`string_repeat`, `string_pad_start`/`_end`, `string_from_char_code`, `md_has_heading`), but three statically-obligated sites stay unguarded and store a negative silently: the effect-operation argument of a USER-declared effect — the built-in `State` `put`/`resume` boundaries and the built-in `Exn` `throw` payload are obligated and codegen-guarded, the latter with the #1203 sign/widening pair on unrefined payloads and, on a refined payload, the §2.6.5 predicate guard (its lowered check includes the base's range) plus the independent `@Nat`-to-`@Int` widening guard ([#1268](https://github.com/aallan/vera/issues/1268)) — (codegen's `_effect_ops` carries only the dispatch target for the rest), the generic-instantiated constructor field (constructor layouts carry no per-field `@Nat` mono metadata), and the `nat_to_int`/`nat_to_string` conversion builtins (special-cased before the `_fn_nat_params` guard loop) — no runtime guard is emitted at those three sites; the unguarded residual is disclosed statically as an E504 warning instead. (The static obligation now also covers the function **return** position — an `@Int` value narrowing into a `@Nat` return is an E503 obligation backed by a codegen return guard, #758 — and the value-position tuple/constructor component, which is both statically obligated and runtime-guarded at the function boundary.) #820 threaded the per-component target-type table into codegen (the metadata these narrowing guards need); the generic-field and effect-op sites are now unblocked for wiring, though the narrowing direction is not yet guarded here. | [#754](https://github.com/aallan/vera/issues/754), [#757](https://github.com/aallan/vera/issues/757) | +| **Statically**, every narrowing *binding site* is obligated: a provably-negative value is an E503 error, and an untranslatable narrowing at an unguarded site is an E504 warning — so a program whose narrowings are all statically PROVEN stores no negative `@Nat` at these sites. `vera verify` exits 0 on the E504 warnings too, so verify-clean is the weaker property: a disclosed narrowing is one the static proof did not reach, and at an unguarded site nothing else does either. **At runtime** (an unverified `vera compile`/`run`), the guard covers every concrete *direct* binding site (a nested constructor sub-pattern is the exception, #765 below), generic function-formal calls, and the string/markup builtin `@Nat` parameters (`string_repeat`, `string_pad_start`/`_end`, `string_from_char_code`, `md_has_heading`), but four statically-obligated sites stay unguarded and store a negative silently: the effect-operation argument of a USER-declared effect — the built-in `State` `put`/`resume` boundaries and the built-in `Exn` `throw` payload are obligated and codegen-guarded, the latter with the #1203 sign/widening pair on unrefined payloads and, on a refined payload, the §2.6.5 predicate guard (its lowered check includes the base's range) plus the independent `@Nat`-to-`@Int` widening guard ([#1268](https://github.com/aallan/vera/issues/1268)) — (codegen's `_effect_ops` carries only the dispatch target for the rest), the generic-instantiated constructor field (constructor layouts carry no per-field `@Nat` mono metadata), the `nat_to_int`/`nat_to_string` conversion builtins (special-cased before the `_fn_nat_params` guard loop), and a **tuple component at construction** (the built-in variadic `Tuple` carrier has no per-field metadata either) — no runtime guard is emitted at those four sites; the unguarded residual is disclosed statically as an E504 warning instead. (The static obligation now also covers the function **return** position — an `@Int` value narrowing into a `@Nat` return is an E503 obligation backed by a codegen return guard, #758 — and the value-position constructor component, which is both statically obligated and runtime-guarded where the field is CONCRETE. The value-position *tuple* component is the fourth unguarded site above: measured `tier3_unguarded`/E504 with no guard emitted, at construction and at the function boundary alike — a tuple that is destructured is checked at the destructure, one that is only returned or passed on is not checked at all.) #820 threaded the per-component target-type table into codegen (the metadata these narrowing guards need); the generic-field and effect-op sites are now unblocked for wiring, though the narrowing direction is not yet guarded here. | [#754](https://github.com/aallan/vera/issues/754), [#757](https://github.com/aallan/vera/issues/757) | | A refinement / `@Nat` narrowing bound in a **nested** constructor sub-pattern (`Some(Some(@PosInt))` on `Option>`) is statically obligated — a `vera verify`-clean program is sound; a bad nested refined narrowing is `E505`, a bad nested `@Nat` narrowing is `E503` (#763) — but the runtime guard covers only the `@Nat` nested bind (verified by run probes: a negative payload traps in both direct and closure positions) — the *refined* nested bind is not guarded, so an unverified compile doesn't trap on a violating nested refined payload; the verifier discloses that residual as `tier3_unguarded`/E506 | [#765](https://github.com/aallan/vera/issues/765) | | A refinement whose base carries a **non-plain type argument** (`{ @Array<{ @Int \| ... }> \| array_length(...) > 0 }`, an `Array` base) gets **no runtime guard at any boundary** — `_refinement_guard_parts` cannot spell the binder slot for such a base, so named-function and closure parameters and returns alike leave the predicate unchecked at run time. The verifier discloses this honestly (`tier3_unguarded`, E506, excluded from runtime-checked totals — `_refined_boundary_codegen_guardable` mirrors the codegen bail, KEEP IN SYNC); the guard itself is the open work. Same disclosure family as #754 / #757 / #765. | [#1036](https://github.com/aallan/vera/issues/1036) | | Contracts that depend on a `handle[...]` expression's value fall to Tier 3 — the verifier does not model the `handle[...]` expression's value or handler-clause state, so an `ensures` over a handle result is runtime-checked rather than statically proved. (Primitive-op and binding obligations *inside* the handle body walk at enclosing-scope precision and can prove Tier-1; a clause body's slot-dependent obligations are fresh-scope Tier-3, while a manifest violation there — `5 / 0` — stays a loud compile error.) | [#439](https://github.com/aallan/vera/issues/439) | diff --git a/README.md b/README.md index 1277a6b7..bd24b82f 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ cp /path/to/vera/SKILL.md ~/.claude/skills/vera-language/SKILL.md ## Project status -Vera is in **active development** at v0.1.12: 2,000+ commits, 210 releases, 11,960 tests, 95% Python code coverage, 244 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. +Vera is in **active development** at v0.1.12: 2,000+ commits, 210 releases, 11,969 tests, 95% Python code coverage, 244 conformance programs, 42 examples, and a 14-chapter specification. Known bugs and limitations are tracked in **[KNOWN_ISSUES.md](KNOWN_ISSUES.md)**. See **[HISTORY.md](HISTORY.md)** for how the compiler was built. The reference compiler — parser, AST, type checker, contract verifier (Z3), WASM code generator, module system, browser runtime, and runtime contract insertion — is working. The language specification is in draft across [14 chapters](spec/). diff --git a/ROADMAP.md b/ROADMAP.md index a5cc2e52..e2eef446 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,7 +12,7 @@ Ordering derives from the design principles ([DESIGN.md](DESIGN.md)): verificati ## Where we are -11,960 tests, 244 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. +11,969 tests, 244 conformance programs, 42 examples, 14 spec chapters. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) tracks the open bugs — burndown material rather than stage work — plus the *limitations* the stages below retire. ## The v0.1.13 burndown diff --git a/TESTING.md b/TESTING.md index f6c1b6cd..c4e548cb 100644 --- a/TESTING.md +++ b/TESTING.md @@ -6,7 +6,7 @@ This is the single source of truth for Vera's testing infrastructure, coverage d | Metric | Value | |--------|-------| -| **Tests** | 11,960 across 175 files (~155,000 lines of test code; 11,761 passed + 26 stress-deselected, 173 skipped) | +| **Tests** | 11,969 across 175 files (~155,000 lines of test code; 11,770 passed + 26 stress-deselected, 173 skipped) | | **Compiler code coverage** | 95% Python, 87% JavaScript (CI minimum: 80%) | | **Conformance programs** | 244 programs across 9 spec chapters, validating every language feature | | **Example programs** | 42, all validated through `vera check` + `vera verify` | @@ -72,9 +72,9 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_naming_env_provenance_1208.py` | 45 | 2,097 | The other half of the #1208 contract: every consumer is handed the ENVIRONMENT the checker rendered under, not just the same renderer. Four provenance seams, each pinned by the adversarial probe that exhibited it — an IMPORTED callee's contract rendered in its DEFINING module's alias namespace (a violated precondition that vanished, and its mirror, a correct call spuriously rejected, both under a `Cnt` that names different bodies on the two sides), an imported GENERIC monomorphized and verified in that same namespace (a lying postcondition that proved clean, plus a verifier↔codegen clone differential over the recounted slot references — the desync is invisible to a unit test on either side), a `forall` variable shadowing a same-named module alias wherever a generic signature renders (the mono clone, a body `let`, the verifier's collapsed premises, and the exported uninstantiated template), and the tester's `SmtContext` holding the narrowed scope its own names were keyed in — the last one behavioural, since a generator handed the wrong scope collapses two parameters onto one variable and returns NO inputs at all. Three of the seams are also crossed against a SECOND component rather than checked for internal consistency, because a wrong-but-consistent scope is invisible from inside one: the verifier's declared parameter names against `slots.slot_table`'s, its `where`-helper scope against `slots.fn_scopes`' accumulation, and the monomorphizer's post-substitution names against what the consumers rebuild on the clone — each independent on the axis under test (which variables the two sides narrow by) but sharing `fn_slot_scope`/`slot_name` below it, so the hand-derived literal rendering beside every comparison is what a shared-renderer defect cannot satisfy. An imported generic nested under a non-generic function is pinned too — both its discovery-time recount and its verification-time clone must run in the DEFINING module's namespace. Three more seams arrived from the PR #1224 review, each with the false Tier-1 or miscompile that exhibited it: an UNPINNED callee (an imported generic's own `where`-helper, which the origin registry never pins) rendering in the module under verification rather than the entry program, whose absence let a violated precondition discharge as true and trap at run time; a callee's refined-RETURN predicate translated in the callee's namespace alongside its `requires`/`ensures`, pinned by provenance because today's bare-headed binder masks it behaviourally; and codegen's declaration-index space keyed PER NAMESPACE, without which a module's stamp turned the main file's forward alias reference into a backward one and a check-clean, verify-clean program read the wrong parameter through valid WASM. Two seams from the #1213 burndown close the same shape from the other side: the prelude's own aliases are injected only into the reserved `Vera` namespace, so no name a program can spell resolves on the codegen side alone (#1221 — the differential compares the checker's and codegen's partition of one signature, with the emitted WAT beside it), and an imported ADT is ordered at the index its OWN module gave it rather than the built-in floor (#1227), each with the control that differs by exactly the namespace under test. Controls carried alongside: the same programs with the alias renamed or the shadowing declaration removed, and the runtime oracle that shows the new E500/E501 agrees with the emitted code rather than merely reporting more | | `test_callee_contract_scope_1220_1225_1226.py` | 40 | 1,433 | A callee's contract is READ in the callee's own module. Three burndown defects, each asserted against the runtime oracle wherever the two directions of a wrong namespace (an obligation that vanishes, one that fires for no reason) look identical from inside the verifier: an E501's `Precondition:` line quoted from the file that DECLARED the clause (#1220 — in the misattribution direction too, both files carrying a plausible `requires` on the same line number, plus the imported-generic `where`-helper whose clause sits past the end of a short importer and used to quote nothing at all); a bare-name call inside an IMPORTED callee's contract resolved through the CALLEE's module registry (#1225 — the false Tier-1 whose run traps and its mirror spurious E501, through the `requires` and the `ensures` path, against a no-collision control); and the refined-RETURN binder derived through the naming layer, so a refinement over a PARAMETERISED base pushes the key its own predicate looks itself up under (#1226 — single-module and cross-module, the second proving the derivation happens INSIDE the callee scope); plus the PR #1239 review round — a module's pinned registry holds what its OWN file imports (a DEPTH-2 chain, the shape the single-level corpus could not exhibit: requires and ensures directions, bare-vs-qualified tier agreement, a name outside the middle module's import filter still missing per §8.5.1, and the mirror gate that filling the registry does not re-export), and every part of a diagnostic follows the declaring module (location, file name and excerpt, including a clause past a shorter importer's end, with a multi-line clause quoted whole), and the characterization of the binder-reference walk's one documented exception — a CLOSURE inside a predicate owns the first `@T.n` in traversal order, whose consequence is a Tier-3 demotion rather than a fact assumed about the wrong term; and the mini-review round — an obligation carries the FILE its line number belongs to, so the documented `(file, line, column)` join between the two `--json` arrays holds for a module-located obligation, with the entry-file control and warm==cold parity, and a multi-line clause is quoted with its `--` comments blanked (a `--` inside a string literal surviving, which a naive split would corrupt) | | `test_alias_application_refinement_base_1237.py` | 11 | 398 | A parameterised alias APPLICATION substitutes its arguments in the verifier's own resolver (#1237). `type Box = T;` applied as `@Box` resolved to `AdtType('T')` — the alias's binder leaking as an ADT name — so a refinement over it failed the modelled-primitive gate, the refined-return fact was dropped, and a valid program was rejected with a spurious E501 while `vera run` returned the right answer. Both halves of the fix are asserted separately: the alias body registers its own parameters as type variables (`substitute` maps type variables, so an ADT-named binder is unsubstitutable however the application side is written) and the application substitutes. Plus depth (an argument that is itself an application, and an alias whose body applies another alias), the bounded direction (a consumer wanting `>= 100` where the refinement grants `>= 18` is still rejected, and the runtime agrees), and the gate that must NOT move — an unmodelled `@Byte` base resolves correctly and still degrades to a Tier-3 runtime check, with a consumer of its predicate still refused | -| `test_exn_throw_payload_1268.py` | 42 | 1049 | `throw`'s payload is obligated AND runtime-guarded like every other narrowing site (#1268). `throw(0 - 5)` under `effects(>)` verified at 4/4 Tier 1 with ZERO obligations while `vera run` returned -5 out of the `@Nat` payload — check-green, verify-green, silently wrong — because `throw` is a bare call with no function-registry entry, so the argument loop never saw it and the table-driven fallback added for the same hole at the State `put` was keyed on that one name. All three arms are asserted (the `@Nat` refutation, the refined refutation over a modelled base, and the `@Nat`->`@Int` widening obligation appearing where none existed), plus the #1251(b) concrete gate reaching the payload for free (`throw(200)` into an `Exn<{ @Byte \| @Byte.0 < 10 }>` names the value; the satisfying twin proves at Tier 1), the user-effect contrast that localized the bug (a declared op's argument was loud for the same value — both are loud now, at the same site name), and the refined-alias payload spelling. The GUARD is checked against codegen rather than asserted: an undischargeable payload must land on the runtime-guarded `tier3` leg, counted in the totals, and a run must confirm the payload really is stopped — the two together go red whichever side moves without the other (delete the emission and the runs go red; flip the flag back and the statuses do). Both arms are run at the boundary: a `@Nat` payload traps on -5 and delivers 5, a refined `{ @Int \| @Int.0 > 0 }` payload traps through the `$vera.contract_fail` channel on BOTH -5 and 0 — the value that clears the base's `>= 0` and violates the predicate, so a sign guard standing in for the predicate guard fails here — and delivers 7. The type gate carries its own over-refusal control: an `Exn` payload has no invariant to violate, so a negative one is a correct program and must still return -5. Both REPRESENTATIONS are covered — a scalar payload in one local, and a `@String`-based one whose (ptr, len) pair has to be saved in two, checked over the ptr and put back in the right order (the satisfying twin is what shows the order). The **soundness differential** is the point of the guard rather than a property of it: the clause parameter's type is what the verifier hands every downstream consumer, so a consumer discharging `ensures(@Bool.result)` at Tier 1 from its `@Nat` parameter alone is asserted PROVED and then run — pre-fix the run reported a postcondition violation on that proved postcondition, with nothing else in the path (the argument is already `@Nat`-typed, so no call-site narrowing guard fires). A dischargeable twin proves the site is not merely always-loud, and the six `Exn` conformance programs are verified whole as canaries so obligating a position that had none names itself instead of arriving as one line of a corpus sweep. The adversarial round adds the three places the `guarded` PROMISE and the emitted guard could disagree in a direction no value oracle can see, because the program either never runs or runs identically either way: a refinement OVER a refinement is asserted `tier3_unguarded` AND `E618`-refused at compile in one cell (the mirror claimed a runtime check for a program that cannot be compiled at all — either half alone reads as consistent); the bare and qualified spellings of one operation are asserted to record IDENTICAL statuses as a differential rather than two literals, on both arms, with the run confirming which value is the true one (`Exn.throw` was disclosed unguarded while codegen delegated it to the guard-emitting dispatcher, and `State.put` had been since #1203); the #820 INTERSECTION at this boundary — a refinement over `@Int` keeps the widening obligation AND its guard beside the predicate's, pinned as a differential against the unrefined spelling (both must trap at u64.MAX, where the refined one used to return -1) with an in-range control so the guard is not simply always-on; and `E504`'s rationale is read from a real diagnostic — reached through the site that IS still unguarded, a user-declared effect's operation argument — to pin that it no longer lists the `throw` payload among the sites with no runtime guard | +| `test_exn_throw_payload_1268.py` | 42 | 1056 | `throw`'s payload is obligated AND runtime-guarded like every other narrowing site (#1268). `throw(0 - 5)` under `effects(>)` verified at 4/4 Tier 1 with ZERO obligations while `vera run` returned -5 out of the `@Nat` payload — check-green, verify-green, silently wrong — because `throw` is a bare call with no function-registry entry, so the argument loop never saw it and the table-driven fallback added for the same hole at the State `put` was keyed on that one name. All three arms are asserted (the `@Nat` refutation, the refined refutation over a modelled base, and the `@Nat`->`@Int` widening obligation appearing where none existed), plus the #1251(b) concrete gate reaching the payload for free (`throw(200)` into an `Exn<{ @Byte \| @Byte.0 < 10 }>` names the value; the satisfying twin proves at Tier 1), the user-effect contrast that localized the bug (a declared op's argument was loud for the same value — both are loud now, at the same site name), and the refined-alias payload spelling. The GUARD is checked against codegen rather than asserted: an undischargeable payload must land on the runtime-guarded `tier3` leg, counted in the totals, and a run must confirm the payload really is stopped — the two together go red whichever side moves without the other (delete the emission and the runs go red; flip the flag back and the statuses do). Both arms are run at the boundary: a `@Nat` payload traps on -5 and delivers 5, a refined `{ @Int \| @Int.0 > 0 }` payload traps through the `$vera.contract_fail` channel on BOTH -5 and 0 — the value that clears the base's `>= 0` and violates the predicate, so a sign guard standing in for the predicate guard fails here — and delivers 7. The type gate carries its own over-refusal control: an `Exn` payload has no invariant to violate, so a negative one is a correct program and must still return -5. Both REPRESENTATIONS are covered — a scalar payload in one local, and a `@String`-based one whose (ptr, len) pair has to be saved in two, checked over the ptr and put back in the right order (the satisfying twin is what shows the order). The **soundness differential** is the point of the guard rather than a property of it: the clause parameter's type is what the verifier hands every downstream consumer, so a consumer discharging `ensures(@Bool.result)` at Tier 1 from its `@Nat` parameter alone is asserted PROVED and then run — pre-fix the run reported a postcondition violation on that proved postcondition, with nothing else in the path (the argument is already `@Nat`-typed, so no call-site narrowing guard fires). A dischargeable twin proves the site is not merely always-loud, and the six `Exn` conformance programs are verified whole as canaries so obligating a position that had none names itself instead of arriving as one line of a corpus sweep. The adversarial round adds the three places the `guarded` PROMISE and the emitted guard could disagree in a direction no value oracle can see, because the program either never runs or runs identically either way: a refinement OVER a refinement is asserted `tier3_unguarded` AND `E618`-refused at compile in one cell (the mirror claimed a runtime check for a program that cannot be compiled at all — either half alone reads as consistent); the bare and qualified spellings of one operation are asserted to record IDENTICAL statuses as a differential rather than two literals, on both arms, with the run confirming which value is the true one (`Exn.throw` was disclosed unguarded while codegen delegated it to the guard-emitting dispatcher, and `State.put` had been since #1203); the #820 INTERSECTION at this boundary — a refinement over `@Int` keeps the widening obligation AND its guard beside the predicate's, pinned as a differential against the unrefined spelling (both must trap at u64.MAX, where the refined one used to return -1) with an in-range control so the guard is not simply always-on; and `E504`'s rationale is read from a real diagnostic — reached through the site that IS still unguarded, a user-declared effect's operation argument — to pin that it no longer lists the `throw` payload among the sites with no runtime guard | | `test_refinement_binder_convergence_1208.py` | 16 | 459 | Codegen's refinement boundary guard and `vera/naming.py` derive the predicate binder ONCE (#1208). A per-shape differential over a direct refinement, an alias hop, `@Nat`- and `@Byte`-based refinements (both range-conjoining), a composite base whose binder is a RESOLVED argument list, and two non-refinements that must be `None` on both sides — plus the property the convergence exists to hold, that the guard's binder equals the key a predicate's own `@Base.n` resolves to. A per-shape differential is green either way while two copies agree — which is exactly how a duplicated derivation drifts unnoticed — so the load-bearing assertion is a MUTATION: perturb `naming.refinement_binder_parts` and codegen's guard must report the perturbed binder. Codegen's two layered WASM decisions are pinned alongside — the loud E618 for a nested refinement base, reported ONCE per declaration however many call sites consult the derivation and however many clones a generic is instantiated into (two genuinely distinct sites still report twice), and no guard at all for an erased one (parametrized over `@Unit` and `Future`, the corner that erases identically but is not spelled `Unit`) — plus runtime traps proving BOTH the `@Byte` and the `@Nat` range conjunctions reach the emitted check, each pinned by trap `kind` as well as by the conjunct its message names. The mutation perturbs the predicate as well as the binder name: the two travel by different routes, and the range conjunction lives on the predicate. The once-per-site dedup is keyed on a resolved location, so a cross-module pin holds up the premise that a location carries its owning file: two imported library modules of identical shape, declaring their nested refinement at the same line and column, must produce two diagnostics attributed to two files — each quoting its own module's declaration, which is also what catches an attribution pointing past the importer's last line | -| `test_checker_effects.py` | 90 | 1,455 | Effect declarations, abilities, effect subtyping, async effect, handler typing (#420 split), and the #1149 built-in-effect redeclaration gate (E152: divergent and faithful `effect IO`, codegen-only `Exn`, a registry-parametrised sweep, and a differential pinning the gate's name set to what `vera effects --json` publishes) | +| `test_checker_effects.py` | 90 | 1,465 | Effect declarations, abilities, effect subtyping, async effect, handler typing (#420 split), and the #1149 built-in-effect redeclaration gate (E152: divergent and faithful `effect IO`, codegen-only `Exn`, a registry-parametrised sweep, and a differential pinning the gate's name set to what `vera effects --json` publishes) | | `test_state_exn_registration.py` | 30 | 1296 | #1210 — State/Exn host-import registration covers the whole handler, not just its body. Four shapes, one per sub-expression position the walk used to miss (a nested handler in a clause body, in the state-init expression, in a clause's `with` update, and an `Exn` handler in a clause body), each check-green and verify-clean and therefore required to COMPILE — pre-fix every one died at whole-module WAT compilation with `unknown func` / `unknown tag`; plus the `i32_pair` cell (`handle[State]` in a `pure` function) that the walk skipped in silence, now the same loud E607 the declared-effect gate emits. The **registration-completeness differential** is the cross-component invariant itself: over every `examples/` + `tests/conformance/` program that compiles, every `state_*` / `exn_*` symbol the emitted WAT REFERENCES must have a matching import or tag DECLARATION — a desync between the registration pass and the lowering pass is invisible to a unit test on either. Round two adds the Exn twin of the silent skip (`handle[Exn]` in a `pure` function — the walk called the shared tag registration and discarded the verdict, so it compiled where the declared-row spelling was a clean E612) and the four CONTRACT positions, which are lowered code: a handler in a `requires`, an `ensures`, an `assert`, or a `decreases` measure. The differential gained a **validation leg** — every HANDLER-BEARING module is handed to `wasmtime.Module` through the exceptions-enabled engine `execute()` uses, because a symbol declared at the WRONG TYPE passes the name comparison while being invalid WASM, and 10 of the 30 handler-bearing modules fail to load with `wasm_exceptions` off — a supported wasmtime configuration, though the current runner defaults it on. The conformance suite's deliberate negatives are filtered out of the sweep: they never reach codegen through `vera check`. Carries floors on programs swept, modules validated, summed symbol references and globally distinct symbols, plus three can-go-red tests: the State and Exn extractions each stripped of their declaration lines, and a planted retyped import that only the validation leg catches. Round five adds the three positions no corpus program contained — a destructuring `let`'s value (which also disarmed the E612 gate), a module call's ARGUMENTS, and a signature refinement predicate reached through the alias table — and a cross-module shape test for the module-call leg. Round seven adds the boundary-guard routes that enumeration missed (a tuple parameter's components, a tuple return's, and a closure's refined formal and return) and the co-extensiveness half those shapes cannot show: a refined tuple behind a CLOSURE formal must declare nothing, since the closure path emits no component guards, and the nested-refinement (E618) and erased-base bails must stay silent registrars too. Plus the cycle guard on the closure signature leg, asserted in both directions — the walk terminates, and with the guard neutered the same walk blows the recursion limit | | `test_closure_lift_boundaries_1234_1235_1245.py` | 18 | 757 | Closure lifting at refinement boundaries — three burndown defects of one seam. **#1245**: `_lift_pending_closures` ran BEFORE `_compile_postconditions`, so a closure created while lowering a refined-RETURN guard, a tuple return's component guards, or an `ensures(...)` predicate was registered and never lifted — the table stayed empty, its `call_indirect` was orphaned, and the #1185 propagation dropped the function and every caller: a check-green, verify-clean program compiling to ZERO exports. The param-position twin (lowered before the lift, so it always worked) is carried as the control that makes it an ORDERING defect, the `ensures`-clause twin shows the same bug with no refinement in sight, and a violating return asserts the lifted guard ENFORCES rather than merely existing. **#1234**: the lift worklist fed itself — a refinement whose predicate holds a closure refined by a type whose chain leads back to it (`type SelfRef = { @Int \| ... fn(@SelfRef -> @Int) ... }`, and equally a mutual `A -> B -> A` or a three-type cycle) had each lift's own boundary guard queue an `AnonFn` for ever, and `vera compile` never returned. All three cycle lengths are asserted on a daemon thread with a wall-clock budget, so a regression fails fast instead of hanging the suite, and each on the [E602] naming the closure it refused (a guard that never fired cannot produce it). Two controls carry the other half — the guard is keyed on the lift CHAIN, not on everything already lifted, so `fn f(@R, @R -> @Int)` and a diamond, which each legitimately lift one predicate's closure twice, must still run; mutation-measured, they are the only two tests a seen-set spelling reddens. **#1235**: a `Tuple` formal crossing into a closure was unguarded where the named path traps — both spellings of the same boundary are run against each other, violating and passing | | `test_byte_literal_joins_1212.py` | 25 | 759 | #1212 — a `@Byte` literal inside a value-position join lowers at the i32 Byte width. `@Byte` is i32 (spec §11) while an int literal defaults to `i64.const`, and the #865 / #1092 coercions each tested for a TOP-LEVEL `IntLit` — which the checker's bidirectional coercion is equally happy to type inside an `if` or `match` branch. Ten write boundaries are parametrized with the literal in a branch (`let`, handler state-init, clause-dispatched `put`, bare `put`, get-clause `resume` — verbatim the form the E602 clause-lowerability skip message recommends — a clause `with` update, a `@Byte` call argument, a generic constructor field at `Box`, a lifted closure's own RETURN whose named twin had been coerced since #865 while the closure path had no such step, and a HETEROGENEOUS join at a `@Byte` return, where the arm the result-type decider reads is already i32 and a sibling is a bare literal — arm ORDER decided which way that one failed, so both orders and both paths are pinned). The module docstring states what that list is and is not: measured coverage, since the checker's single Byte coercion makes the true enumeration "every position propagating a Byte expectation", which nothing enumerates in one place, each a check-green program that failed WASM validation with `type mismatch: expected i32, found i64` before the fix. Every case carries a VALUE oracle (200, distinguishable from every other constant in its fixture) rather than merely asserting the module runs, and a separate test drives the OTHER branch so a fix that marked only the arm the result type is read off would still fail. The controls are the load-bearing half: a plain `@Int` join must stay i64 — pinned on 5,000,000,000, which an i32 store cannot represent, so a spreading mark is a wrong VALUE and not just a validation failure — a Byte join with no literal arm must be untouched, and a Byte-RETURNING literal join must keep its own #865 return coercion. The constructor-field case runs through the real pipeline (checker artifacts threaded), because the #1092 width keys on the checker-recorded target type | @@ -84,12 +84,12 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_new_state_family_1285.py` | 9 | 323 | #1285 — which cell `new(State)` reads under a multi-`State` effect row. `old()` has been family-keyed since #1205/#1209 while `new()` read the name-keyed op registry, so the two sides of one `ensures` clause read different cells: `effects(, State>)` with `ensures(new(State) == …)` was check-green and verify-green, put `state_get_Int`'s i64 into the Bool comparison's `i32.eq`, and died at load. Three multi-row cases — the width-mismatched shape that could not load, an `Int`/`Nat` pair that loaded and answered about the wrong cell, and `old()` beside `new()` of one family, whose unchanged-cell claim the runtime refuted on a contract the verifier had discharged — plus the single-`State` and alias-spelled controls the whole existing corpus exercises. Each cell is seeded from a caller's handler at a value the other cell is not holding, so a wrong-cell read cannot coincide with the right answer, and a deliberately false postcondition asserts the Tier 3 runtime check really traps, without which every "the program runs" assertion here would prove nothing | | `test_adt_membership_scope_1253.py` | 5 | 334 | #1253 — a checker↔codegen DIFFERENTIAL over one module's slot table. `_adt_layouts` is one map across every absorbed namespace, so a sibling module's ADTs were members of a module that never imported them while the checker kept the name opaque: `['Array', 'Array']` against `['Array', 'Array']` for the same signature. Each case renders the module's parameters through `vera.naming` twice — once against the environment the checker binds that module's declarations in (built by the production `_modules_visible_to` + `check_program` path, not a rebuild of it) and once against codegen's `_alias_env` inside `_module_alias_scope` — and asserts both the agreement and the checker's own value, so an alignment on the WRONG name still fails. Three membership cases (an unimported public sibling, a private sibling, and the imported positive control that is green before and after — what separates scoping the membership from erasing cross-module ADTs) plus the entry program's own view, which must keep seeing the ADT it imports by name | | `test_prelude_decl_stamp_1287.py` | 4 | 240 | #1287 — the prelude's declaration-index block is a fact about the prelude. `_stamp_decl_order` guarded the PRELUDE write on `_decl_order`, the active (main-file) namespace, so a main-file `type Option = Int` — accepted under §8.4.1, and not a `data`, so it does not suppress the prelude's own `Option` — made the guard fire and left `Option` out of `_prelude_decl_order` entirely, with every later prelude declaration shifted one place earlier because the counter never advanced. That map is the base layer under every module's index space (`{**prelude, **module_own}`), so the wrong index reached `AliasEnv.data_types` as `_BUILTIN_DECL_INDEX`. Stated as an INVARIANCE — the same program with and without the shadowing alias must stamp an identical prelude block — plus the module-namespace index it feeds, and a control that the main file's own stamp still wins its own namespace (which a fix stamping `_decl_order` unconditionally would break) | -| `test_prelude_adt_namespace_1277.py` | 52 | 983 | #1277 — one file's `data Json` must not evict the prelude's from another namespace, and a module declaration contending with a prelude one must be loud. Three halves, pinned by disjoint cases so a regression in any is attributable. **Acceptance battery**: all eight prelude ADT names × {module declares it alone, entry also uses the prelude's}, asserting that no cell reports `[E602]`/`[E620]` and that a cell which does not report `[E621]` emits every public function the entry declares — the silent-drop check, and the guard against the rail's original four-of-eight coverage returning (the layout harvest skips a built-in name, so a layout-keyed rail saw `data Json` and never `data Option`). Plus two-declaring-module cells in both import orders — the entry's import order is derived from the parametrization, and the both-differ cell asserts the two reports arrive in that order, so the pair cannot quietly become one program compiled twice — covering restate+differ, both-differ, both-restate, and a non-prelude control that must stay E609's, because the rail asks every declarer and a first-wins lookup made it order-dependent. Plus the restatement control for all eight: a module that restates the prelude's shape shares the one layout, compiles and runs, and must not be refused — measured legal at the branch point, and refused by the rail's first form for four of them. **Rail detail**: severity, the module's own file and line, the description naming the type and the module, the empty exports, and `cmd_compile` returning 1 over a `cmd_check`-green program. **Floor**: the checker registers the prelude ADTs in every `TypeEnv` unconditionally, so codegen's membership must too; asserted as a differential against the checker's own `data_types` in a module namespace, and on the entry namespace's member set for the issue's measured shape. `prelude_adt_names()` is compared against `inject_prelude` itself, and `data_decl_shape` is pinned on the directions that matter — a renamed type parameter is the same layout, a reordered constructor is not, an alias-spelled restatement keys EQUAL to the prelude's, and a type parameter shadows an alias of its own name. Each declaration is resolved through the aliases of the namespace it was written in, one side only: the two whole-program cells pin both directions of that — a module restating the prelude through `type Payload = String;` must compile, and a module hiding a mismatch behind `type Array = Int;` must not | +| `test_prelude_adt_namespace_1277.py` | 61 | 1034 | #1277 — one file's `data Json` must not evict the prelude's from another namespace, and a module declaration contending with a prelude one must be loud. Three halves, pinned by disjoint cases so a regression in any is attributable. **Acceptance battery**: all eight prelude ADT names × {module declares it alone, entry also uses the prelude's}, asserting that no cell reports `[E602]`/`[E620]` and that a cell which does not report `[E621]` emits every public function the entry declares — the silent-drop check, and the guard against the rail's original four-of-eight coverage returning (the layout harvest skips a built-in name, so a layout-keyed rail saw `data Json` and never `data Option`). That battery accepts either answer per cell by design, so the §8.4.1 injection split is pinned separately: an entry that never names the type must still report `[E621]` for the four every program compiles (`Option`, `Result`, `Ordering`, `UrlParts`) and must stay clean for the four injected on demand, with a partition cell holding the two halves to the battery's own name list. Plus two-declaring-module cells in both import orders — the entry's import order is derived from the parametrization, and the both-differ cell asserts the two reports arrive in that order, so the pair cannot quietly become one program compiled twice — covering restate+differ, both-differ, both-restate, and a non-prelude control that must stay E609's, because the rail asks every declarer and a first-wins lookup made it order-dependent. Plus the restatement control for all eight: a module that restates the prelude's shape shares the one layout, compiles and runs, and must not be refused — measured legal at the branch point, and refused by the rail's first form for four of them. **Rail detail**: severity, the module's own file and line, the description naming the type and the module, the empty exports, and `cmd_compile` returning 1 over a `cmd_check`-green program. **Floor**: the checker registers the prelude ADTs in every `TypeEnv` unconditionally, so codegen's membership must too; asserted as a differential against the checker's own `data_types` in a module namespace, and on the entry namespace's member set for the issue's measured shape. `prelude_adt_names()` is compared against `inject_prelude` itself, and `data_decl_shape` is pinned on the directions that matter — a renamed type parameter is the same layout, a reordered constructor is not, an alias-spelled restatement keys EQUAL to the prelude's, and a type parameter shadows an alias of its own name. Each declaration is resolved through the aliases of the namespace it was written in, one side only: the two whole-program cells pin both directions of that — a module restating the prelude through `type Payload = String;` must compile, and a module hiding a mismatch behind `type Array = Int;` must not | | `test_import_visibility_entry_point_1244.py` | 6 | 269 | #1244 — `vera check` reports the same diagnostics whether it was given a module or a file that imports it. Registration alone says what a module DECLARES; the importer never checked its bodies, so a name a module never imported was rejected standalone (E200, §8.5.1) and accepted in silence through an importer. Written as EQUALITY between the two entry points rather than as "the importer warns", because the property is agreement — a future change making the standalone verdict lenient would satisfy a one-sided assertion and must fail here on the standalone leg. Six cases: the leaked unimported name, the honest control that imports what it uses (green before and after, so the new body check is the visibility rule rather than a blanket rejection of cross-module programs), the issue's type-error-through-importer shape (an `@Int` call bound to a `@Bool` slot: check-clean, Tier-1, failing at compile), a diamond proving each module is reported ONCE (the body check is memoised by path across nested checkers), and both entry points into an import cycle proving the memo terminates it | | `test_clone_body_declaring_module_1241_1243.py` | 5 | 344 | #1241 + #1243 — an imported generic's clone body resolves its bare calls in the DECLARING module, on both sides. The verifier's lexical lookup fell through to the importer's registry (`_declaring_module_scope` swapped the naming env, source and file but not the function registry) and codegen's clone-emission door was the one door that did not thread the module's intra-rename map, so `glib`'s `gen` called the importer's `need`. The two halves are one routing rule, and the tests are written so neither passes alone: each case asserts the `vera verify` verdict AND the `vera run` value together, so the verifier half alone (which makes verify clean while the compiled program still traps on the postcondition it just proved — the measured false Tier-1) fails the same test the codegen half alone (right value, verify still refusing) fails. Every expected value comes from the module verified and run STANDALONE, never from what the importer produces. Shapes: a private direct callee, a two-hop private chain, the type-discriminating pair (`@Int` vs `@Bool` — check-green source that emitted invalid WASM), and the unshadowed-callee control that was correct before and after, which is what pins the defect to the SHADOWED name rather than to cross-module calls in general | | `test_module_generic_namespace_1274.py` | 24 | 1,015 | #1274 — a module generic that does not own the importer's bare name is reached under `mod$$name`. Pre-fix only PRIVATE module generics were routed that way (#1000), so a PUBLIC one collided with the importer's same-named generic in the clone-name space: both files' `gen2` mangled to one `gen2$Bool`, one overwrote the other, and the module's own body ran the importer's — a **false Tier-1** (`check`/`verify` clean, the module's proved `ensures` violated at run: 999 where the declaring module answers 111). The full visibility matrix (module generic × importer generic), the import-filter dimension (out-of-filter, in-filter, wildcard), the unshadowed-out-of-filter cell that assembled to `unknown func $gen2`, and a type-discriminating shape whose two clones have different WAT result types. Each cell asserts the verify VERDICT and the runtime VALUE together in one test — a clean verify beside a violated postcondition IS the bug, so splitting them across sibling tests would let it hide — against the standalone library as oracle, and re-checks that the importer's own generic still answers its own value. The per-module both-sides differential lives in `test_monomorphize_differential.py`. Two further families joined after the adversarial round: the module→module **hop** — a module's bare call to a DIFFERENT module's qualified-only generic, which the per-module classification never rerouted, in both a LOUD spelling (a contract pins the answer, so a captured call traps) and a SILENT one (every contract admits both answers, so only the value distinguishes them) plus the two-hop shape where the entry never imports the declaring module at all; and the **shared-input** pair, which pins that the two sides compute the importer's occupied bare names identically — codegen reads them after Pass 0's helper renames, the verifier from the pre-transform AST, and a non-generic `where`-helper named `gen2` made the same imported generic bare-name-owning on one side and qualified-only on the other. The idempotence of that derivation is asserted directly across BOTH Pass-0 transforms and their composition — over a fixture carrying every helper shape it distinguishes (non-generic under non-generic, under a generic parent, under a generic helper, and a generic helper), since a fixture missing one would let a partial assertion look total — with each shape's membership pinned individually beside it, because idempotence alone would hold for a derivation that answered the same WRONG set every time. Two more families close the visibility dimension: the **transitive** one, driven through the production `ModuleResolver` (a hand-built `ResolvedModule` defaults `direct=True` and would never reach the path), asserting that a module reached only transitively has ALL its generics qualified-only — the entry's namespace does not hold them at all; and the **user-written qualified call** (`deep::gen(true)` where the importer declares its own `gen`), which must key its instantiation to the module's declaration rather than to whoever owns the bare name | | `test_ambiguous_import_refusal_1304.py` | 40 | 1,202 | #1304 — two imports supplying one bare name are refused, in every namespace. Spec §8.5 ordered a local declaration against an import and gave the qualified form for a clash it hides, but defined no order between two IMPORTS of one name, and neither did the implementation: a module importing two dependencies that each export `forall fn gen` — one `@Int`-returning, one `@Bool` — bound its bare call to whichever supplier a set of module paths yielded first, so one unchanged file was check-green on one run and `[E121] body has type Bool` on the next (at the branch point: accepted on hash seeds 0, 2 and 3, rejected on 1, 4, 5, 6 and 7). The load-bearing cells are the DETERMINISM ones — each import order checked in four fresh subprocesses under four `PYTHONHASHSEED` values, asserting one byte-identical verdict including message and location, which the base tree cannot satisfy and which a merely deterministic PICK would also fail (the refusal is what removes the choice). Around them: the refusal is definition-gated like the E608 rail it generalises, so an unused clash is still refused and swapping a bare call for the qualified form does not lift it; the two escape hatches — a local declaration (§8.5.2) and a selective import — are asserted to their RUNTIME VALUE, since a disambiguation resolving to the wrong supplier is silent at check and wrong at run; four non-ambiguous controls (one supplier, disjoint names, a private namesake, an out-of-filter namesake) hold the refusal to bare-name ambiguity; and the emitted code is held to a typecheck-phase range, because reusing a codegen code would carry #1304's own complaint — a scope question enforced at the wrong layer — into the fix. A subprocess canary pins that every fresh interpreter measures this checkout | -| `test_module_generic_collision_1281.py` | 20 | 797 | #1281 — E608 must not refuse two modules' PROVABLY DISTINCT generics. A generic emits nothing under its bare name, and since #1274 its clones live in a namespace chosen per OWNER, so the diamond (`base` public, `mid1` private, both named `gen`) cannot overwrite anything — and was refused outright, with `vera verify` returning rc=0 beside the refusal. Each door now answers its own module's generic (555 + 111) against the standalone oracles, and the emitted module carries `mod$…$mid1$gen$Bool` and `mod$…$base$gen$Bool` with nothing in the entry's bare clone namespace. The relaxation is gated on three conditions, each with its own cell: both declarations are top-level generics (a generic beside a non-generic keeps the refusal), at most one owns the bare name (asked of the predicate directly, since end to end the ambiguity gate catches that shape first), and no namespace can name both — a module importing two dependencies that each export `gen` would resolve its own bare call to one of them, and spec §8.5 refuses the name outright rather than ordering the two imports (issue 1304). The CHECKER reports that (E155) and this rail is its backstop, so both cells drive the shape through `build_multi_module_past_check` and assert both layers: a rail no test can reach is one that can rot into a relaxation nobody measures. A namespace that declares its own `gen` is not ambiguous however many dependencies export one (§8.5.2). The registration half — a qualified-only generic contributing no bare `_fn_sigs` or `_fn_ret_type_exprs` entry — is pinned by two STRUCTURAL cells, one per table, and its docstring says why: with #1299's scope narrowing in place both withholdings are defence in depth, reverting them leaves every suite and the whole conformance corpus green, and they are kept only because four consumers read those tables per NAME and nothing but their current internals stops each from picking one | +| `test_module_generic_collision_1281.py` | 20 | 809 | #1281 — E608 must not refuse two modules' PROVABLY DISTINCT generics. A generic emits nothing under its bare name, and since #1274 its clones live in a namespace chosen per OWNER, so the diamond (`base` public, `mid1` private, both named `gen`) cannot overwrite anything — and was refused outright, with `vera verify` returning rc=0 beside the refusal. Each door now answers its own module's generic (555 + 111) against the standalone oracles, and the emitted module carries `mod$…$mid1$gen$Bool` and `mod$…$base$gen$Bool` with nothing in the entry's bare clone namespace. The relaxation is gated on three conditions, each with its own cell: both declarations are top-level generics (a generic beside a non-generic keeps the refusal), at most one owns the bare name (asked of the predicate directly, since end to end the ambiguity gate catches that shape first), and no namespace can name both — a module importing two dependencies that each export `gen` would resolve its own bare call to one of them, and spec §8.5 refuses the name outright rather than ordering the two imports (issue 1304). The CHECKER reports that (E155) and this rail is its backstop, so both cells drive the shape through `build_multi_module_past_check` and assert both layers: a rail no test can reach is one that can rot into a relaxation nobody measures. A namespace that declares its own `gen` is not ambiguous however many dependencies export one (§8.5.2). The registration half — a qualified-only generic contributing no bare `_fn_sigs` or `_fn_ret_type_exprs` entry — is pinned by two STRUCTURAL cells, one per table, and its docstring says why: with #1299's scope narrowing in place both withholdings are defence in depth, reverting them leaves every suite and the whole conformance corpus green, and they are kept only because four consumers read those tables per NAME and nothing but their current internals stops each from picking one | | `test_lexical_fn_scope_1299.py` | 56 | 1,554 | #1299 — codegen's bare-call ownership table must be the CALL SITE's lexical scope. The #1284 predicate is one rule over two tables, and codegen's was `set(_fn_sigs)`: every symbol the whole compilation absorbed, including names the compiling body cannot see, so a bare `get(())` the checker resolved to a `State` operation was lowered as a call to some other declaration. Four routes, all check-green — an imported module's **private** `get`, a **public** one a selective import excludes, a `where` helper of a **`forall` parent** (which keeps a bare key beside its clone-qualified one where a non-generic parent's does not), and the ability operation `show`, which E151 does not reserve and which reaches the same table through the INTRINSIC gate rather than the op one. Where the widths agreed the module loaded and answered the invisible declaration's value (7007 for the cell's 42007); where they differed it failed to load; the generic-`where` route is always loud (`unknown func $get`). Every expected value is the checker's, PROVEN by a type oracle rather than assumed — the invisible `get` returns `@Bool` while the caller returns `@Int` from it and checks green — and each route carries a rename control. The visibility matrix (public/private × in-filter/excluded/wildcard × shadowed/unshadowed × direct/transitive) asserts the verify verdict and the runtime value together per cell. The two directions are pinned at once: the sibling loses the name, the generic TEMPLATE keeps it (asserted on the emitted instruction stream, since monomorphization supersedes the template and a value assertion would be green either way), and a lifted closure — compiled through its own `WasmContext` — inherits its parent's scope. Four table invariants sit beside them: the scoped set is a subset of the registry, every `$`-bearing key stays in it, prelude names stay in it, and every emission door supplies a declaration its own helpers | | `test_phantom_generic_instances_1271.py` | 14 | 358 | #1271 — discovery inside a still-generic scope must not instantiate a callee at an ENCLOSING scope's type VARIABLE. `pick(@U.1, @U.0)` inside `forall fn helper` bound `pick`'s variable to the NAME `U`, so a `pick$U` clone was emitted whose parameter has no WASM type and which the compilability pass then skipped with a loud `[E604]` — the noise that kept #1223's shapes out of the conformance suite. Drives the four #1223 fixtures plus a mutual-recursion shape (two sibling generic helpers under a generic parent, whose phantoms include one arriving through a callee's declared RETURN type, `leaf$W`), asserting on ONE compile that no clone is keyed by a type variable, that no E602/E604/E605 skip is emitted, AND that the genuinely concrete clone is still there — the third assertion being what separates the filter from an over-filter that would take the real instantiation with it. Plus the **primitive-spelled binder** matrix (`forall`, ``, ``, ``), each row instantiating a sibling at exactly the type its binder is spelled like — a shared `idw(5)` would have let every row but `Int` pass for free — asserted on the clone set AND on the program still running; with the `Q`-binder control that keeps a genuine type variable filtered, so the fix cannot degenerate into "never filter". That control CREATES a live phantom candidate — a generic helper under a generic parent, handing its callee an argument typed by its own binder — because a control that merely fails to create one holds under any filter including none; mutation-checked by disabling the filter, which turns it red | | `test_handle_exn_divergent_result_1276.py` | 10 | 391 | #1276 — a `handle[Exn]` whose clause body AND handled body both diverge emitted a result-LESS `block` into a result-expecting context: check-green, verify-green, rejected at load with `type mismatch: expected i64 but nothing on stack`. Four divergent shapes (the issue's Int rethrow, the `Byte` payload spelling #1269 unmasked, a three-deep rethrow chain, and a clause diverging through both arms of an `if`), each asserted on valid WASM AND on the observable — the OUTER handler's clause value, 1000. Paired with the Unit TWIN, which infers `None` for the same reason but DOES complete: it must keep running and its WAT must contain no `unreachable` at all. The pairing is the point — `result_wt is None` means two things wanting opposite lowerings, and a fix that terminated both would trap a program that runs. The MIRROR family covers a clause that throws on one path and COMPLETES on the other (`if` and `match` spellings), where the inference read only the `then` branch / arm 0, answered `None`, and left the completing path's value stranded in a result-less block; the `if` case appears twice with different thrown values so both the throwing and the completing path are exercised from one inference | @@ -101,7 +101,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_db_marshalling.py` | 35 | 234 | #229 — the `` marshalling helpers: `Array>` params (inbound reader), `Array>>` query grids (`_alloc_result_ok_rows`) and `Result` row-counts, round-tripped through an `InstanceCaller` over a real compiled module — each case run normally AND under `VERA_EAGER_GC=1` (every `$alloc` fires `$gc_collect`), the large-grid case forcing free-block reuse; mutation-validated (dropping a shadow-stack root corrupts the read-back / SIGBUSes the swept-pointer read) | | `test_db_runtime.py` | 21 | 301 | #229 — the `` host binding (`vera/runtime/db.py`) on stdlib `sqlite3`: create/insert/select round-trips against `:memory:`, NULL cells → `None`, the affected-row count (incl. the `-1` DDL sentinel), a BLOB cell UTF-8-decoded with replacement, the `Err`-not-crash error path, an unopenable `VERA_DB_URL` deferred to an `Err` (not a host crash), and injection-safety (a malicious param binds as a literal, table intact); plus `_open_connection`'s `VERA_DB_URL` surface (memory + file URLs, in-memory default) and `register_db`'s bind/no-op paths | | `test_sql_provenance_309.py` | 79 | 780 | #309 — the SQL literal-provenance gate (SQL injection as a compile-time error): non-literal SQL rejected `E207` (bare param slot, function result, `\(expr)` interpolation, `string_concat` with a runtime operand, let-bound runtime value, `if`-expression), literal / concat-of-literals / let-chain-with-shadowing / empty-string accepted, placeholder/param arity `E208` with quote- and comment-aware counting (named/numbered placeholders are rejected outright, `E209`), the `count_placeholders`↔sqlite3 differential (exact count accepted, one too many rejected), and gate scoping — a user `effect DB` shadow is rejected at its declaration (`E152`, #1149) *and* its runtime SQL still draws `E207` alongside it (defence in depth), an unrelated effect's `query` is not gated, and no `E207` cascade onto a mistyped SQL arg | -| `test_checker_modules.py` | 242 | 2,606 | Module-call diagnostics, cross-module typing, visibility enforcement, builtin redefinition (function E151 and effect E152 surfaced from a module into its importer), reserved function names (E153 — the contract state forms `old` / `new` and the keyword class `assert`/`assume`/`forall`/`exists`/`match`/`if`/`let`/`fn`/`true`/`false`, each top-level, `where`-helper, and module-surfaced, plus the `handle` host-invoked carve-out and the probe record behind both halves; the twenty-one *contextual* keywords `then`/`else`/`data`/`type`/`module`/`import`/`public`/`private`/`requires`/`ensures`/`invariant`/`decreases`/`effect`/`with`/`in`/`where`/`pure`/`ability`/`effects`/`op`/`result`, derived from `grammar.lark` rather than hand-listed and reachable rather than traps — each declared, was called and answered its value before the fix — over five parametrized batteries (declaration, visibility, `where`-helper, a rationale free of the keyword branch's false unreachability claim, and a usable per-name fix suggestion) with `handle` and fifteen keyword-containing names as controls; and `resume`, reserved on separate grounds — not a keyword, so the declaration parses and outside a handler a bare call reaches it, but it collides with the resumption binding every clause body carries, and the pins cover the rejection, the where-helper depth, that the rationale carries none of the other two branches' false claims, that handler-clause `resume(...)` still checks AND that a wrongly-typed one is still E202 — the pair, since a binding that accepted anything would satisfy the first alone — and that the rejected declaration draws no second error out of the correct clause bodies it used to shadow, at both top level and where-helper depth), parsed module calls (#420 split) | +| `test_checker_modules.py` | 242 | 2,632 | Module-call diagnostics, cross-module typing, visibility enforcement, builtin redefinition (function E151 and effect E152 surfaced from a module into its importer), reserved function names (E153 — the contract state forms `old` / `new` and the keyword class `assert`/`assume`/`forall`/`exists`/`match`/`if`/`let`/`fn`/`true`/`false`, each top-level, `where`-helper, and module-surfaced, plus the `handle` host-invoked carve-out and the probe record behind both halves; the twenty-one *contextual* keywords `then`/`else`/`data`/`type`/`module`/`import`/`public`/`private`/`requires`/`ensures`/`invariant`/`decreases`/`effect`/`with`/`in`/`where`/`pure`/`ability`/`effects`/`op`/`result`, derived from `grammar.lark` rather than hand-listed and reachable rather than traps — each declared, was called and answered its value before the fix — over five parametrized batteries (declaration, visibility, `where`-helper, a rationale free of the keyword branch's false unreachability claim, and a usable per-name fix suggestion) with `handle` and fifteen keyword-containing names as controls; and `resume`, reserved on separate grounds — not a keyword, so the declaration parses and outside a handler a bare call reaches it, but it collides with the resumption binding every clause body carries, and the pins cover the rejection, the where-helper depth, that the rationale carries none of the other two branches' false claims, that handler-clause `resume(...)` still checks AND that a wrongly-typed one is still E202 — the pair, since a binding that accepted anything would satisfy the first alone — and that the rejected declaration draws no second error out of the correct clause bodies it used to shadow, at both top level and where-helper depth), parsed module calls (#420 split) | | `test_checker_errors.py` | 73 | 1,196 | Error codes, resolution-coverage diagnostics, contracts, error accumulation (#420 split); cyclic type aliases incl. #1059 self-reference through a type argument (`Future`, mutual `Future`/`Future`, `Array`) rejected E132 | | `test_checker_builtins_collections.py` | 97 | 848 | Map / Set / Decimal / Json / Html / Http / Inference built-in type-checking (#420 split) | | `test_checker_builtins_strings.py` | 122 | 945 | String / numeric / type-conversion / float-predicate / string-search / markdown / regex built-in type-checking, removed-legacy-name regression (#420 split) | @@ -127,7 +127,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_nat_int_widening.py` | 36 | 602 | #813 — `@Nat -> @Int` widening coercion obligation (dual of #552 `nat_bind`, part of the #392 soundness audit): a `@Nat` in (i64.MAX, u64.MAX] reinterprets when widened (u64.MAX → -1), so a `nat_to_int_coerce` obligation that the value is `<= i64.MAX` now fires at the return position — provably-in-range → Tier-1, provably-out-of-range (`@Nat.0 >= 2**63`) → loud E530, unbounded → honest Tier-3 (runtime-guarded), with an `@Int -> @Int` control that must not fire; the unguarded generic-`@Int`-field case also has its `E531` rationale read for WHAT IT SAYS — a value bounded on neither side, not the untranslatable-or-timeout conflation #1251 removed. The #813 follow-up adds the explicit `nat_to_int` built-in and heterogeneous `if`/`match` arms with a non-negative-literal alternative; #820 adds the heterogeneous-`@Int`-slot arm, closure argument, and closure return/capture obligations (each per-arm / per-site, with `@Int`-arm and `@Nat`-formal controls that must not fire) | | `test_int_widening_codegen.py` | 52 | 535 | #813 Stage 3 — runtime `@Nat -> @Int` widening-trap codegen: the codegen emits a guard at *exactly* the `@Nat -> @Int` coercion sites the verifier obligates (return, `let`, call argument, and — since #820 — array element, tuple construction/destructure, heterogeneous `if`/`match` arm, closure argument/return), so `vera run`/`vera compile` programs trap when a `@Nat` above i64.MAX would reinterpret to a negative `@Int` instead of silently returning the wrong value. The trap is a bare `unreachable` (shares `_emit_negative_i64_guard` with the #552 nat-bind guard), classified `kind="unreachable"` today (a dedicated widening trap kind is a follow-up) | | `test_int_widening_differential.py` | 26 | 320 | #813 verifier↔codegen behavioural differential (cross-component soundness rule): at every `@Nat -> @Int` coercion site the verifier's `nat_to_int_coerce` classification must AGREE with the runtime — a `tier3` (codegen-guarded) site MUST trap on a `@Nat` above i64.MAX (return / `let` / call-arg / constructor field / ADT sub-pattern / match-bind, and the #820 array-element / tuple-component / heterogeneous-arm / closure argument-return sites), while a `tier3_unguarded` (E531) site must NOT trap (the generic-instantiated `@Int`-field coercion codegen cannot guard). Runs BOTH sides on one corpus so the "runtime-guarded" claim is checked against the actual trap — catching a verifier deferral codegen never guards (unsound silent -1) or a spurious trap | -| `test_nat_narrowing_return_differential.py` | 136 | 2,893 | #758 verifier↔codegen behavioural differential (cross-component soundness rule): at the function-return `@Int -> @Nat` coercion slot the verifier's `nat_bind` verdict must AGREE with the runtime — an unproven narrowing leaves the return obligation undischarged (loud E503, or an honest `tier3` for an opaque value) and codegen's return guard TRAPS on a negative input, while a proven narrowing (`requires` / path condition) discharges at Tier 1 and the guard is dead (`vera run` returns the value, no trap). Runs BOTH sides on one corpus so "the verifier obligates this return" is checked against the actual guard — the return-position dual of `test_int_widening_differential`. #983 review adds the `tier3` quadrant (opaque `float_to_int`, verify + compile in one run), `let_before_tail` / `nested_if_join` join shapes, a `type Count = Nat` alias case, and threads `file=` + `resolved_modules=` through the verify side for CLI-pipeline fidelity. #1017 adds the `apply_fn` ARGUMENT-narrowing quadrant (the `@Int -> @Nat` dual of the #820 argument widening): a provably-negative arg is E503, a runtime arg is obligated + `call_indirect`-guarded (run traps), a `requires`-bounded arg proves Tier 1, a `@Nat -> @Nat` arg is unobligated, and an opaque `float_to_int` arg records `tier3` with the codegen `i64.lt_s`/`unreachable` guard emitted (verify + compile cross-checked in one pipeline run). #1024 adds the REFINED apply_fn-argument quadrant (`refine_bind`, the refinement dual of #1017): an argument narrowing into a `{@Nat \| @Nat.0 > 0}` closure formal discharges the FULL predicate refined-first — a constant `0` is E505 (clears the `@Nat` base's `>= 0` but violates `> 0`), a runtime arg is obligated + guarded at the lifted closure's prologue (run(0) traps with a `contract_violation` Refinement-violation message), a constant `5` / `requires`-bounded arg proves Tier 1, and a `@Pos -> @Pos` arg is unobligated. #1032 adds the REFINED closure-RETURN quadrant (the return-side dual of #1024): `fn(@Int -> @Pos) { @Int.0 }` records exactly one tier3 `refine_bind` (opaque body — never a false Tier 1), run(-5) AND run(0) trap at the lifted body's return guard with the "return value" refinement message, a satisfying value passes, and the always-satisfying body stays an honest tier3 with no spurious trap — plus the re-derived single-guard pin (exactly one `contract_fail` refinement check in the lifted body, zero `i64.lt_s` narrowing checks). PR #1202 adds the #1203 handler-boundary quadrants (init/put/with/resume × trap/pass/zero, bare-put and clause-body-put dispatch shapes, widen duals at U64_MAX with i64.MAX boundary controls) and the #1205 scalar-alias family quadrants: alias and refined-alias `State` cells compile and run, every #1203 guard keys through the alias (init/put trap on negatives, widen dual at U64_MAX), the alias-equal annotation binds clause slots under its SOURCE name, a stateless handler's clause `@T.0` reaches the op ARGUMENT (the pre-fix capture skew read the cell — pinned in both directions), `Exn` compiles with the payload bound under the clause pattern's name, `old(State)` snapshots through the collapsed family, and the retired lying-annotation fixture is pinned as check-rejected E336. The second adversarial round adds the clause-scope checker-parity battery (mixed-spelling State and Exn shapes bind under the checker's canonicalized argument names, both patternless twins bind nothing, the declaration-scope shadow probe), the parameterised-alias family differentials (`State>`, alias-of-generic-alias, `Exn>`), and the `State` write-boundary battery (init/clause-put/bare-put/`with`/resume literals at i32) | +| `test_nat_narrowing_return_differential.py` | 136 | 2,897 | #758 verifier↔codegen behavioural differential (cross-component soundness rule): at the function-return `@Int -> @Nat` coercion slot the verifier's `nat_bind` verdict must AGREE with the runtime — an unproven narrowing leaves the return obligation undischarged (loud E503, or an honest `tier3` for an opaque value) and codegen's return guard TRAPS on a negative input, while a proven narrowing (`requires` / path condition) discharges at Tier 1 and the guard is dead (`vera run` returns the value, no trap). Runs BOTH sides on one corpus so "the verifier obligates this return" is checked against the actual guard — the return-position dual of `test_int_widening_differential`. #983 review adds the `tier3` quadrant (opaque `float_to_int`, verify + compile in one run), `let_before_tail` / `nested_if_join` join shapes, a `type Count = Nat` alias case, and threads `file=` + `resolved_modules=` through the verify side for CLI-pipeline fidelity. #1017 adds the `apply_fn` ARGUMENT-narrowing quadrant (the `@Int -> @Nat` dual of the #820 argument widening): a provably-negative arg is E503, a runtime arg is obligated + `call_indirect`-guarded (run traps), a `requires`-bounded arg proves Tier 1, a `@Nat -> @Nat` arg is unobligated, and an opaque `float_to_int` arg records `tier3` with the codegen `i64.lt_s`/`unreachable` guard emitted (verify + compile cross-checked in one pipeline run). #1024 adds the REFINED apply_fn-argument quadrant (`refine_bind`, the refinement dual of #1017): an argument narrowing into a `{@Nat \| @Nat.0 > 0}` closure formal discharges the FULL predicate refined-first — a constant `0` is E505 (clears the `@Nat` base's `>= 0` but violates `> 0`), a runtime arg is obligated + guarded at the lifted closure's prologue (run(0) traps with a `contract_violation` Refinement-violation message), a constant `5` / `requires`-bounded arg proves Tier 1, and a `@Pos -> @Pos` arg is unobligated. #1032 adds the REFINED closure-RETURN quadrant (the return-side dual of #1024): `fn(@Int -> @Pos) { @Int.0 }` records exactly one tier3 `refine_bind` (opaque body — never a false Tier 1), run(-5) AND run(0) trap at the lifted body's return guard with the "return value" refinement message, a satisfying value passes, and the always-satisfying body stays an honest tier3 with no spurious trap — plus the re-derived single-guard pin (exactly one `contract_fail` refinement check in the lifted body, zero `i64.lt_s` narrowing checks). PR #1202 adds the #1203 handler-boundary quadrants (init/put/with/resume × trap/pass/zero, bare-put and clause-body-put dispatch shapes, widen duals at U64_MAX with i64.MAX boundary controls) and the #1205 scalar-alias family quadrants: alias and refined-alias `State` cells compile and run, every #1203 guard keys through the alias (init/put trap on negatives, widen dual at U64_MAX), the alias-equal annotation binds clause slots under its SOURCE name, a stateless handler's clause `@T.0` reaches the op ARGUMENT (the pre-fix capture skew read the cell — pinned in both directions), `Exn` compiles with the payload bound under the clause pattern's name, `old(State)` snapshots through the collapsed family, and the retired lying-annotation fixture is pinned as check-rejected E336. The second adversarial round adds the clause-scope checker-parity battery (mixed-spelling State and Exn shapes bind under the checker's canonicalized argument names, both patternless twins bind nothing, the declaration-scope shadow probe), the parameterised-alias family differentials (`State>`, alias-of-generic-alias, `Exn>`), and the `State` write-boundary battery (init/clause-put/bare-put/`with`/resume literals at i32) | | `test_hetero_widen_tailcall.py` | 21 | 312 | The heterogeneous per-arm widen guard vs tail calls (#986) and targets: an arm whose `@Nat` value is a tail call must lower to a plain `call` so the appended guard stays live (`return_call` would skip it — the widening dual of the #983 per-leaf narrowing), the genuine `@Int` arm's recursive `return_call` keeps TCO (100k-depth run), the gate is target-aware (`_is_hetero_int_widen_join`: a hetero join in a `@Nat`-returning context must NOT widen-guard its legal `@Nat` arm — the target-blind gate false-trapped 2^63), and a user `data Tuple` must not take the builtin variadic carrier's target-table path (verifier emits no obligation there; guarding it was an opposite-direction desync) | | `test_xmod_span_collision.py` | 4 | 156 | #987 — the span-keyed target-type table is single-module (keyed by bare span, no file identity): an imported body's expression span can coincide with a main-file entry. #987 threads each module's OWN table into codegen (`CheckArtifacts.module_artifacts` → `_compile_fn(module_tables=...)`), so the engineered line-for-line collision pair now proves the legal all-`@Nat` imported function is not falsely widen-guarded by CORRECTNESS (its own table targets `Tuple`), not merely suppression — with a `thread_modules=False` control pinning the #986 suppression fallback still holds when no module tables are threaded, and a same-file control proving top-level guards unaffected | | `test_xmod_widening_differential.py` | 18 | 293 | #987 verifier↔codegen widening differential run THROUGH THE IMPORT DOOR (the same-file `test_int_widening_differential` was green while this door was open): for each cross-module shape (array-element, tuple-construction, tuple-destructure control, transitive 3-level, and shadowed-import) the library's standalone verify must classify the `@Nat -> @Int` coercion Tier-3, AND the importing program compiled the way `vera run`/`vera compile` compile it (per-module tables threaded) must TRAP at `u64.MAX` — never the silent -1 — while passing `2^63-1` and `42` unchanged. Pins that the #820 array/tuple-construction guards, recovered from the span-keyed target table, now fire for imported bodies. Also pins the import-door trap is the guard's bare `unreachable` net (not some other trap), and a two-independent-libraries-both-widen scenario asserting BOTH imported bodies trap at `u64.MAX` (kills a first-module-only partial-collection mutant) | @@ -152,7 +152,7 @@ python scripts/check_wheel_availability.py # pre-flight: every runtime | `test_dropped_entry_1183_1186.py` | 21 | 561 | A dropped entry function is refused, never silently replaced (#1183), and an imported body's skip locates in its own module (#1186). #1183: the repro — declared `main` dropped, one public sibling surviving — exits nonzero with `main` and the root [E602]/[E620] named, and the sibling's 4243 sentinel (a value no fallback, default, or error path produces) never appears on stdout; the same for an explicit `--fn`, for the `--json` envelope (`ok: false`), and at the `execute()` library boundary; `CompileResult.dropped_fns` is pinned as the reified source of the refusal; the `Compilation notes:` block appears when a sibling survives (the ungating); auto-selection survives for the never-declared case and prints a one-line stderr note naming its choice; zero-export `compile` exits nonzero in both text and JSON; and the browser bundle refuses a dropped `main` using the SURVIVING-sibling fixture, so a non-empty export list rules out the zero-export check as the cause. #1186: the root E602 carries the MODULE's path with module-local line/column and quotes the module's source line, the [E620] cross-file prefix fires with its exact wording derived from the root's own location, a same-file control keeps the bare `at line N, column M` form, and `vera test` names the [E602] root instead of calling a public-but-dropped function private. Mutations — api.py refusal, CLI refusal, the E620 drop record, the module source scope, the tester reason, the notes ungating, the auto-select note, the zero-export gate, the browser refusal — each killed by a named test | | `test_imported_trap_source_map_1189.py` | 8 | 342 | An imported function's runtime trap frame names ITS module's file (#1189), the source-map sibling of #1186's diagnostic fix. Fixtures split the basenames (`chinchilla.vera` module, `stargazer.vera` importer) so a frame's attribution is decidable from the string alone, and every trap is a precondition violation so `WasmTrapError.kind` is pinned. Covers the three doors: an imported non-generic fn (pre-fix `` — never registered on the main generator), a monomorphized clone of an imported generic (pre-fix the IMPORTER's path with the module's line range, which in the fixture names a real-but-unrelated importer function), and the `mod$…` emission of a locally-shadowed import (whose rightmost-`$` strip yields nobody's entry). Asserted at the `cmd_run` text backtrace (per-frame line, never the whole stderr blob — `main` legitimately names the importer), the `--json` frames array, `fn_source_map` itself, and `execute()`'s `WasmTrapError.frames`. Over-correction control: a wholly main-file trap keeps the main file, green before and after. Mutations — the module file on the Pass-0.5 registrar, the bare-name harvest, the mangled-name mirror, the Pass-1.5 module source scope — each killed by a distinct named test | | `test_codegen_typeparam_unit_wildcard_1060.py` | 31 | 959 | Wildcard over a type-parameter field instantiated to `Unit` (#1060), the type-parameter sibling of #1043's declared-`Unit` field: a WILDCARD over `Box` field `T` used to advance the match offset walk by the generic `i32` width, so on `Box` (field erased to 0 bytes) every later field read four bytes high — silently check-green. Bug-manifesting shapes go end-to-end (`Box` trailing-`Int`, `Named` `String` read-back, `Entry` nested-ctor tag, `Bool`-following, second-type-parameter, and a nested-generic `Outer` wrapping `Inner` that exercises the deeper-recursion type substitution); controls stay green (before-erased field, trailing wildcards, `Option`/`Result` builtins, `Box`/`Box`/`Tagged` alignment-coincidence, structural `Eq`/`show` recompute path); the direct-call boundary is pinned (#1065) — a `match mk() { … }` scrutinee recovers its concrete instantiation from the callee's declared return type, so a wildcard followed by a read now compiles and reads the real value (`Box` trailing-`Int`, `Entry` nested-ctor, `Named` `String` read-back) instead of the sound #1060 interim LOUD-skip, while a trailing direct-call wildcard still compiles; the generic-call sibling (#1072) resolves the declared return's type variables from the call site (`P2` at T=Int, the var-typed field at `String` i32_pair width, a fully concrete parameterized return on a generic fn, nested-ctor and `String`-read-back variants, plus a trailing-wildcard control), and the module-call door (#1073) routes `boxlib::mk()` — and the imported-generic #1072 x #1073 compound — through the shared resolver into the same recovery; mutation-validated per arm (reverting the #1060 instantiation-awareness flips exactly the #1060 bug-manifesting shapes RED with declared-`Unit` #1043 tests green; reverting the #1065 declared-return threading flips exactly the three direct-call value shapes RED; neutralizing the #1072 generic arm flips exactly the five generic value shapes + the imported-generic compound RED; neutralizing the #1073 module arm flips exactly the two module tests RED) | -| `test_codegen_alias_adt_name_width_1309.py` | 112 | 418 | A `type` alias whose name is also a registered ADT's must emit the ALIAS TARGET's width (#1309). Codegen's `_type_expr_to_wasm_type` tested `_adt_layouts` — and `Array`/`Map`/`Set`/`Decimal`, none of them primitives — before the alias table, where the checker's `_resolve_named` resolves primitive, then alias, then declared ADT; so `type Option = Int;` emitted the ADT's i32 pointer for an i64 slot on a check-green, verify-green program. Three dispositions are pinned separately because they fail differently: LOUD scalar targets (`Int`/`Nat` i64, `Float64` f64) died at load; PAIR targets are the SILENT ones the issue's "matching widths" prediction missed — an `i32_pair` is two words and the single i32 dropped the length, so `string_concat("ab", "ab")` returned junk bytes and `array_length` over three elements returned 0, both at exit 0; and matching-width targets (`Bool`/`Byte`/`Map`/`Set`/`Decimal`, all i32) are INERT, kept as green-both-sides guards that the reorder leaves them alone. The battery is the differential that makes width-luck unreintroducible: every name in the LIVE built-in ADT registry (read off a real `CodeGenerator`, so a new built-in joins without anyone widening a list) crossed with every representation class, comparing the emitted `twice` signature against the identical program under a fresh alias name, plus the two unit duals — under an alias the derivation answers the target's width, without one it still answers the ADT pointer, so "the alias wins" cannot be satisfied by breaking every ordinary ADT parameter. Primitives are asserted to still shadow a same-named alias — the one branch that must NOT move — across all seven spellings by asking the derivation directly, which is the only way to reach every one of them since two are checker-refused (`@Bool.0 + @Bool.0` is E140, `type Int = Int;` is E132); a run-level program carries the behavioural half, because `type Bool = Int;` used AS a Bool is check-green, runs, and distinguishes the hoist mutant. An earlier draft claimed the checker refuses every program that would exercise this, which is measured false. A separate block covers the THIRD consumer of the same disease (CR on PR #1323): `_return_type_is_string` tested the `Future` transparency strip before the alias table, so under `type Future = Array;` a `@Future` return was classified a string and `execute()` decoded the array's backing bytes as UTF-8 — two NULs where the fresh-name control printed the pointer, measured identically at the branch point and so pre-existing. `String` stays ahead of the alias branch there too, being the one primitive involved, and three over-correction controls hold the #841/#1047 transparent-`Future` decode and PR #1041's alias-to-`Future` shape. `Json` and `HtmlNode` are deliberately outside the prelude-ADT row: their prelude combinator bodies render against the flat alias map a main-file shadow pollutes, which is an alias-env SCOPING defect (#1316) the reorder does not reach — though it does MOVE that failure (17 prelude `json_*` signatures flip width, the loader's complaint reverses direction, `html_attr` loses a push), so "fails identically" — an earlier draft's wording — is measured false | +| `test_codegen_alias_adt_name_width_1309.py` | 112 | 423 | A `type` alias whose name is also a registered ADT's must emit the ALIAS TARGET's width (#1309). Codegen's `_type_expr_to_wasm_type` tested `_adt_layouts` — and `Array`/`Map`/`Set`/`Decimal`, none of them primitives — before the alias table, where the checker's `_resolve_named` resolves primitive, then alias, then declared ADT; so `type Option = Int;` emitted the ADT's i32 pointer for an i64 slot on a check-green, verify-green program. Three dispositions are pinned separately because they fail differently: LOUD scalar targets (`Int`/`Nat` i64, `Float64` f64) died at load; PAIR targets are the SILENT ones the issue's "matching widths" prediction missed — an `i32_pair` is two words and the single i32 dropped the length, so `string_concat("ab", "ab")` returned junk bytes and `array_length` over three elements returned 0, both at exit 0; and matching-width targets (`Bool`/`Byte`/`Map`/`Set`/`Decimal`, all i32) are INERT, kept as green-both-sides guards that the reorder leaves them alone. The battery is the differential that makes width-luck unreintroducible: every name in the LIVE built-in ADT registry (read off a real `CodeGenerator`, so a new built-in joins without anyone widening a list) crossed with every representation class, comparing the emitted `twice` body in full — header widths and the instructions under them — against the identical program under a fresh alias name, plus the two unit duals — under an alias the derivation answers the target's width, without one it still answers the ADT pointer, so "the alias wins" cannot be satisfied by breaking every ordinary ADT parameter. Primitives are asserted to still shadow a same-named alias — the one branch that must NOT move — across all seven spellings by asking the derivation directly, which is the only way to reach every one of them since two are checker-refused (`@Bool.0 + @Bool.0` is E140, `type Int = Int;` is E132); a run-level program carries the behavioural half, because `type Bool = Int;` used AS a Bool is check-green, runs, and distinguishes the hoist mutant. An earlier draft claimed the checker refuses every program that would exercise this, which is measured false. A separate block covers the THIRD consumer of the same disease (CR on PR #1323): `_return_type_is_string` tested the `Future` transparency strip before the alias table, so under `type Future = Array;` a `@Future` return was classified a string and `execute()` decoded the array's backing bytes as UTF-8 — two NULs where the fresh-name control printed the pointer, measured identically at the branch point and so pre-existing. `String` stays ahead of the alias branch there too, being the one primitive involved, and three over-correction controls hold the #841/#1047 transparent-`Future` decode and PR #1041's alias-to-`Future` shape. `Json` and `HtmlNode` are deliberately outside the prelude-ADT row: their prelude combinator bodies render against the flat alias map a main-file shadow pollutes, which is an alias-env SCOPING defect (#1316) the reorder does not reach — though it does MOVE that failure (17 prelude `json_*` signatures flip width, the loader's complaint reverses direction, `html_attr` loses a push), so "fails identically" — an earlier draft's wording — is measured false | | `test_codegen_pair_scrutinee_1305.py` | 32 | 685 | A `match` whose SCRUTINEE is pair-represented (`String` / `Array`) took one local at the internal `i32_pair` pseudo-type, so the module carried `(local $l1 i32_pair)` and never assembled (#1305). The issue reached it through `json_keys` and framed it as an `Option>` payload binder; the docstring records why measurement does not support that — `json_keys` returns `Array`, and `array_length(json_keys(j))` compiled and ran at the branch point — so the tests are built on the shapes that actually trigger it: `match @String.0 { @String -> … }` and `match @Array.0 { @Array -> … }`, both legal and check-green, plus the slot, call-result and builtin-result scrutinee forms. One test returns the BOUND STRING rather than its length, because a fix that allocated two locals and copied only the pointer passes every length-free assertion. The issue's own repro matches `Some`/`None` against that array; a pair carries no tag, so the assertion is that the module assembles and the refusal is a located E602 — not that the nonsense compiles (the checker accepting it is #1315). The guard is a WHITELIST (wildcard and binding only) and these cells are why: as a blacklist naming the two constructor kinds it let `true ->` and `1 ->` fall through into the arm-condition emitter, turning a loud WAT failure into a check-green program that exits 0 printing 100 from the scrutinee's heap POINTER read as a truth value, and its integer twin into a shipped `.wasm` that died at instantiation with no diagnostic — so five unlowerable-arm cells (bool/int/string literals over both pair spellings) and a nullary-arm-FIRST program pin each half, the last because both original repros led with `Some` and left the nullary half droppable green. The two shadow pushes are pinned as EMISSION by a WAT differential against the match-free twin (exactly two more push idioms) plus a position assertion that no length half is rooted: deleting both pushes leaves the whole suite, the GC rooting and reclamation suites, and four allocate-in-the-arm probes under `VERA_EAGER_GC=1` green, so a behavioural claim would be one no probe supports. The two controls the issue listed as already-compiling are kept as regression guards, and an Option/Result binder battery over `Array`/`Array`/`Array`/`Map`/`Set`/`String`/`Int` payloads plus a nested `Option>>` pins the boundary: the scrutinee change left pair-typed constructor FIELDS alone | | `test_codegen_erased_alias_typeargs_1070.py` | 15 | 398 | Non-literal erases-to-Unit type ARGUMENTS (#1070): `Box` (`type U = Unit;`), `Box>`, `Box`, and alias chains — the #1060 width recomputation's zero-size test was the literal name `Unit`, so these spellings got 4 bytes and every later field read a shifted offset (silent 22→0, nested 314→0); the same literal-test disease made structural `Eq` over the same spellings fall back to the scalar POINTER compare (pre-existing — equal structs compared unequal, silently) and `show`/`hash` loud-skip. Pins every spelling end-to-end: wildcard reads (trailing `Int`, nested ctor), `Eq` equal/distinct + `Future` arg, `show` renders `unit` + the real fields, `hash` payload-sensitive + deterministic, literal-`Unit` controls; the rider — a zero-size `@Unit` BINDING after an unrecoverable wildcard is not a read (compiles), while a genuine read beyond it still LOUD-skips (E602); mutation-validated per site (width fn, field-name canonicalisation, both dispatch gates, derivability gate, rider — each flips exactly its own test subset) | | `test_codegen_alias_typeargs_eq_1076.py` | 34 | 499 | The Eq-dispatch ground-spelling cluster (#1076/#1077/#1078). #1076: structural `==` over NON-Unit alias type args (`Box`/`MyStr`/`MyBool`/`Future`/chains) silently pointer-compared (equal structs → 0, check-green) — equal AND distinct pairs per spelling, i64-width pins with >2^32 payloads whose low 32 bits collide, `MyStr` content-vs-pointer compare, plus the #1060-walk width shapes (`Bool` follows the type-param field, so an i32-sized `MyInt`/`MyStr`/`Future` manifests); a genuine free `T` (dead base-generic clone, #912) still compiles via its scalar fallback. #1077: `show`/`hash` of `Tuple` (raw-args Tuple plan branch) and of bare aliased-Unit values (literal-name top-level arms) loud-skipped — all four now compute (exact renders, payload-sensitive hash), literal controls pinned. #1078: element-wise `==` on arrays of parameterized ADTs (`Array>`, literal included) pointer-compared (the `IndexExpr` operand's element head drops its type args) — equal/distinct/`>2^32`/aliased-element shapes, non-generic-array + direct-compare controls; mutation-validated per site (canonicalization helper, width fn, both dispatch gates, derivability gate, Tuple plan branch, top-level Unit arms, IndexExpr recovery arm — each flips exactly its own subset) | diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 8b1127f7..6c5a566c 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -3265,7 +3265,7 @@ The reference compiler is under active development. The current release includes - A seven-stage pipeline: parse, transform, resolve, typecheck, verify, compile, execute - A 14-chapter formal specification -- 11,960 tests, including a 244-program conformance suite +- 11,969 tests, including a 244-program conformance suite - 42 working example programs - 164 built-in functions covering strings, arrays, math, parsing, and data types - Four built-in abilities (Eq, Ord, Hash, Show) with constrained generics and ADT auto-derivation diff --git a/spec/06-contracts.md b/spec/06-contracts.md index 99e92402..3b7ff81b 100644 --- a/spec/06-contracts.md +++ b/spec/06-contracts.md @@ -266,7 +266,7 @@ To discharge an operation obligation, the programmer encodes the constraint in a **Division and modulo** are Tier-1-decidable — the divisor is a concrete integer term — so an unguarded `a / b` whose Tier-1-translatable divisor admits a zero counterexample is a compile error (E526); a divisor beyond the translatable fragment (a fresh-scope slot in a closure or handler clause, an opaque effect result, a solver timeout) degrades to a runtime-guarded Tier-3 obligation instead, while a manifest zero divisor is E526 in any position. (Float division is exempt: `f64.div` by zero yields inf/NaN, not a trap.) **Array indexing** depends on `array_length`, which the SMT layer models as an *uninterpreted* function (§6.3.2), so bounds reasoning is in general beyond Tier 1. The verifier therefore tiers the obligation honestly: it proves the bound at **Tier 1** when a literal length, refinement, precondition, or path condition pins the length; reports a compile error (**E527**) when the index provably exceeds a statically-known length (e.g. `[1, 2, 3][5]`); and otherwise — a dynamic, opaque length — degrades to a runtime-guarded **Tier 3** obligation (counted in `vera verify --json`, never a silent pass). An index inside a closure body, quantifier predicate, or handler-clause body is walked under a fresh (empty) slot scope; one that depends on a captured length or fresh slot is reported as a runtime-guarded **Tier 3** obligation — beyond the fresh scope's decidable fragment, with the codegen bounds-check backing it — while a literal-only shape still classifies exactly (a manifest out-of-bounds literal is a loud E527) — while an index in a quantifier *domain* or a handler *body* (enclosing-scope positions) is tiered at full precision like any direct-position index; lifting the fresh-scope sites to a Tier-1 proof is the Tier 2 work in [#427](https://github.com/aallan/vera/issues/427). Indexing applies to `Array` only; indexing a `String` is a type error (E161). -The `@Nat` obligations (E502 / E503) carry the most nuance, spanning many binding sites. The verifier emits an E502 obligation `lhs >= rhs` at every `@Nat - @Nat` subtraction site (see [#520](https://github.com/aallan/vera/issues/520)), and an E503 obligation `value >= 0` where an `@Int` value narrows into a `@Nat` **binding** slot — `let`, call-argument, effect-operation-argument, constructor-field, top-level match-bind, and literal-tuple-destructure sites (see [#552](https://github.com/aallan/vera/issues/552)), plus the generic-instantiation, ADT sub-pattern, non-literal-destructure, and cross-module imported-constructor sites (see [#747](https://github.com/aallan/vera/issues/747)), and the function **return** position — an `@Int` value (including an `if`/`match` tail) narrowing into a `@Nat` return is obligated `result >= 0` under the body's path conditions, the dual of the [#813](https://github.com/aallan/vera/issues/813) `@Nat -> @Int` widen-return obligation (see [#758](https://github.com/aallan/vera/issues/758)). The codegen mirrors the subtraction obligation and the `@Nat` binding and return sites with runtime guards — every concrete site (`let`, destructure, match-bind, sub-pattern, concrete constructor field, concrete call-argument) plus **generic function-formal calls**, which guard on the monomorphised callee (the mangled instance `pick$Nat` carries concrete `@Nat` flags). Two sites stay unguarded, both still obligated statically, so a Tier-3 narrowing the solver cannot discharge at either surfaces an E504 warning: a **user-declared effect operation's argument**, whose runtime guard is deferred (see [#754](https://github.com/aallan/vera/issues/754)), and the **generic-instantiated constructor field**, since constructor layouts carry no per-field `@Nat` metadata to monomorphise. The built-in effects' operation arguments are guarded at their op-call sites: the `State` write boundaries (see [#1203](https://github.com/aallan/vera/issues/1203)) and the `Exn` `throw` payload, which also carries the §2.6.5 refinement-predicate guard (see [#1268](https://github.com/aallan/vera/issues/1268)). Division, modulo, and array indexing now follow the same auto-synthesis pattern ([#680](https://github.com/aallan/vera/issues/680)); lifting dynamic or closure-captured array bounds from a runtime-guarded Tier 3 to a Tier-1 proof is part of the Tier 2 verification work in [#427](https://github.com/aallan/vera/issues/427). +The `@Nat` obligations (E502 / E503) carry the most nuance, spanning many binding sites. The verifier emits an E502 obligation `lhs >= rhs` at every `@Nat - @Nat` subtraction site (see [#520](https://github.com/aallan/vera/issues/520)), and an E503 obligation `value >= 0` where an `@Int` value narrows into a `@Nat` **binding** slot — `let`, call-argument, effect-operation-argument, constructor-field, top-level match-bind, and literal-tuple-destructure sites (see [#552](https://github.com/aallan/vera/issues/552)), plus the generic-instantiation, ADT sub-pattern, non-literal-destructure, and cross-module imported-constructor sites (see [#747](https://github.com/aallan/vera/issues/747)), and the function **return** position — an `@Int` value (including an `if`/`match` tail) narrowing into a `@Nat` return is obligated `result >= 0` under the body's path conditions, the dual of the [#813](https://github.com/aallan/vera/issues/813) `@Nat -> @Int` widen-return obligation (see [#758](https://github.com/aallan/vera/issues/758)). The codegen mirrors the subtraction obligation and the `@Nat` binding and return sites with runtime guards — every concrete site (`let`, destructure, match-bind, sub-pattern, concrete constructor field, concrete call-argument) plus **generic function-formal calls**, which guard on the monomorphised callee (the mangled instance `pick$Nat` carries concrete `@Nat` flags). Three sites stay unguarded, all still obligated statically, so a Tier-3 narrowing the solver cannot discharge at any of them surfaces an E504 warning: a **user-declared effect operation's argument**, whose runtime guard is deferred (see [#754](https://github.com/aallan/vera/issues/754)); the **generic-instantiated constructor field**, since constructor layouts carry no per-field `@Nat` metadata to monomorphise; and a **tuple component at construction**, since the built-in `Tuple` carrier's layout has no per-field `@Nat` metadata either — the component's target type is recovered from the threaded target-type table for the `@Nat` -> `@Int` *widening* guard ([#813](https://github.com/aallan/vera/issues/813)), but the narrowing direction is not guarded there. Tuple *destructuring* is guarded, so a tuple that is taken apart is checked on the way out rather than on the way in; one that is only returned or passed on is not checked at all. The built-in effects' operation arguments are guarded at their op-call sites: the `State` write boundaries (see [#1203](https://github.com/aallan/vera/issues/1203)) and the `Exn` `throw` payload, which also carries the §2.6.5 refinement-predicate guard (see [#1268](https://github.com/aallan/vera/issues/1268)). Division, modulo, and array indexing now follow the same auto-synthesis pattern ([#680](https://github.com/aallan/vera/issues/680)); lifting dynamic or closure-captured array bounds from a runtime-guarded Tier 3 to a Tier-1 proof is part of the Tier 2 verification work in [#427](https://github.com/aallan/vera/issues/427). **Integer overflow** ([#798](https://github.com/aallan/vera/issues/798)). `@Int` is a signed 64-bit machine integer and `@Nat` an unsigned one; `+` / `-` / `*` wrap at the i64 / u64 boundary. Like `@Nat` underflow and signed-division `MIN / -1`, an overflowing operation is a *partial* operation that **traps** at runtime rather than silently wrapping, so each `@Int` / `@Nat` `+` / `-` / `*` carries an obligation that the result stays in range. It is classified at the operands' **common (coerced) type** — `@Int` if either operand is `@Int` (since `@Nat <: @Int`), else `@Nat` — not one operand's self-type (a non-negative literal is `@Nat`, but `5 + @Int.0` is an i64 add) nor the possibly-narrowed result type (an `@Int.0 + 1` stored into a `@Nat` slot is still an i64 add). A two-check mirrors array indexing: the result provably in range → **Tier 1**; provably out of range (e.g. a literal `u64.MAX + 1`, or `@Int.0 + 1` under `requires(@Int.0 == i64.MAX)`) → a compile error (**E528**); otherwise — dynamic operands — a runtime-guarded **Tier 3** trap. `@Nat` subtraction is excluded — it is the underflow obligation (E502) above, never a high-overflow. diff --git a/spec/09-standard-library.md b/spec/09-standard-library.md index 58ca43a3..598f008a 100644 --- a/spec/09-standard-library.md +++ b/spec/09-standard-library.md @@ -2304,7 +2304,7 @@ Four rules carry that property. The first two follow from the ADT having no line Together these MUST make `md_render` a fixed point: re-parsing and re-rendering its output returns the same bytes. Unlike the round-trip property this one has no exceptions — a block with no text renders to no lines, which re-renders to no lines. -Two code-span shapes are outside that, and are lost identically on every runtime rather than differently, because the subset has no escape syntax to write them another way: a span needing three or more backticks, at the start of a line, where that run is a fenced-code-block opener; and an empty span, whose rendering reads back as literal text. +Two code-span shapes are outside the **round-trip** property — not the fixed point, which has no exceptions. They are the two that property defers to above, and they are lost identically on every runtime rather than differently, because the subset has no escape syntax to write them another way: a span needing three or more backticks, at the start of a line, where that run is a fenced-code-block opener; and an empty span, whose rendering reads back as literal text. The rules are visible from a value a program builds, which is where they bite: a parser cannot produce an empty list item or a space-bounded code diff --git a/tests/test_checker_effects.py b/tests/test_checker_effects.py index a7bd15a8..be4acea9 100644 --- a/tests/test_checker_effects.py +++ b/tests/test_checker_effects.py @@ -969,7 +969,17 @@ def test_async_over_effectful_user_get_still_warns(self) -> None: """) w002 = [w for w in warnings if w.error_code == "W002"] assert w002, f"expected W002, got: {[w.error_code for w in warnings]}" - assert "IO" in w002[0].description, w002[0].description + # EXACTNESS, not membership: the row attributed to the argument is + # the user declaration's own `` and nothing unioned into it. A + # partial fix that added the shadowed operation's parent effect to + # the walk's answer reports the union here. (`Http` in particular + # is invisible in this clause — it is IN the commutative set, so it + # is subtracted before the message is built; the whole-description + # negative `"Http" not in description` is therefore both untrue, + # since every W002 names the set as `(Http)`, and blind to the + # union it would be asserting against.) + performs = w002[0].description.split(" effects,")[0] + assert performs.endswith("performs IO"), w002[0].description def test_async_over_effectful_helper_under_another_name_warns( self, diff --git a/tests/test_checker_modules.py b/tests/test_checker_modules.py index d7c64bca..ea15e839 100644 --- a/tests/test_checker_modules.py +++ b/tests/test_checker_modules.py @@ -2318,8 +2318,12 @@ def test_fix_suggests_a_usable_replacement(self, name: str) -> None: ``pure_fn``, which is advice no author would take, so the branch carries a per-name suggestion where the generic suffix misleads. Pinned by property — the suggested identifier must be a legal Vera - function name and must not be reserved — rather than by exact - wording, so the table can be improved without churning the test. + function name, must not be reserved, and must not be that generic + template — rather than by exact wording, so the table can be + improved without churning the test. The suggestion is read from + the clause that makes it, not swept out of the whole fix text: + the sweep's other catches are boilerplate, so it went green for a + table entry that had been deleted. """ import re @@ -2335,14 +2339,31 @@ def test_fix_suggests_a_usable_replacement(self, name: str) -> None: {{ 5 }} """) if e.error_code == "E153" ) - quoted = re.findall(r"'([a-z][A-Za-z0-9_]*)'", diag.fix) - suggestions = [q for q in quoted if q != name] - assert suggestions, diag.fix - for s in suggestions: - # Not reserved (E153 again) and not a built-in (E151 instead) — - # advice that trades one error for another is not a fix. - assert s not in _RESERVED_FN_NAMES, (name, s, diag.fix) - assert s not in _builtin_reject_names(), (name, s, diag.fix) + # ANCHORED to the sentence that makes the suggestion. A sweep for + # every quoted lowercase word in `diag.fix` also collects the + # substring example (`'{name}_value'`) and `'handle'` (offered as + # the one surviving keyword, not as a replacement), both of which + # are boilerplate present whatever the per-name table says — so a + # table entry replaced by a prose word, or deleted outright, left + # the sweep with two words that pass every check below. + m = re.search( + r"Rename the function to an identifier that is not a keyword" + r" — '([a-z][A-Za-z0-9_]*)'", + diag.fix, + ) + assert m is not None, diag.fix + suggestion = m.group(1) + assert suggestion != name, diag.fix + # Not the generic `{name}_fn` template: that is the fallback this + # branch's per-name table exists to replace, and `in_fn` / `type_fn` + # / `pure_fn` is the advice the docstring above calls unusable. + assert suggestion != f"{name}_fn", (name, diag.fix) + # Not reserved (E153 again) and not a built-in (E151 instead) — + # advice that trades one error for another is not a fix. + assert suggestion not in _RESERVED_FN_NAMES, (name, suggestion, + diag.fix) + assert suggestion not in _builtin_reject_names(), (name, suggestion, + diag.fix) def test_handle_stays_legal(self) -> None: """NEGATIVE CONTROL: the carve-out survives a derived set. @@ -2405,9 +2426,14 @@ def test_type_namespace_half_is_vacuous(self) -> None: with pytest.raises(ParseError): parse_to_ast(src) # Control: an ordinary LOWERCASE name fails the same way, proving the - # rejection is the case rail and not the reservation. + # rejection is the case rail and not the reservation. All THREE + # positions are controlled — without the constructor one, nothing + # showed that `Holder { with(Int) }` above failed at the case rail + # rather than at the function-name reservation, which is the exact + # confusion this test exists to rule out. for src in ("private data helper {\n MkX(Int)\n}\n", - "type helper = Int;\n"): + "type helper = Int;\n", + "private data Holder {\n helper(Int)\n}\n"): with pytest.raises(ParseError): parse_to_ast(src) diff --git a/tests/test_codegen_alias_adt_name_width_1309.py b/tests/test_codegen_alias_adt_name_width_1309.py index 8b301219..aebde428 100644 --- a/tests/test_codegen_alias_adt_name_width_1309.py +++ b/tests/test_codegen_alias_adt_name_width_1309.py @@ -355,7 +355,12 @@ class TestAliasOverAdtNameWidthBattery: For each pair, compile the alias-named program and the identical program under a fresh alias name, and compare the emitted ``$twice`` - signature — the parameter, local and result widths. A single width + body in full — the parameter, local and result widths of the header, + and the instructions under it. The whole body, not the header alone: + a header-only comparison reads the parameter and result widths but + not the ``(local …)`` declarations, and the module docstring's + "byte-identical to a fresh-name control" is a claim about the body + (measured: identical in every cell of the battery). A single width that resolves through the ADT branch instead of the alias branch shows up here regardless of whether it happens to trap, so the loud cases cannot be the only ones anyone notices. @@ -382,8 +387,8 @@ def test_emitted_widths_match_the_fresh_name_control( assert not aliased_errors, ( f"type {adt} = {target}; failed to assemble: {aliased_errors}") - want = wat_fn_body(control.wat, "twice").splitlines()[0] - got = wat_fn_body(aliased.wat, "twice").splitlines()[0] + want = wat_fn_body(control.wat, "twice") + got = wat_fn_body(aliased.wat, "twice") assert got == want, ( f"type {adt} = {target}; emitted {got!r}, " f"but the same program under a fresh alias name emitted {want!r}" diff --git a/tests/test_exn_throw_payload_1268.py b/tests/test_exn_throw_payload_1268.py index 657a5430..c5d015c1 100644 --- a/tests/test_exn_throw_payload_1268.py +++ b/tests/test_exn_throw_payload_1268.py @@ -561,6 +561,13 @@ def test_a_nested_refinement_payload_promises_nothing(self) -> None: result = _verify(self._NESTED) binds = [o for o in result.obligations if o.kind == "refine_bind"] assert [o.status for o in binds] == ["tier3_unguarded"], binds + # The count is not the bind's: a `tier3_unguarded` discharges to no + # tier and is excluded from `tier3_runtime`. The 1 is the payload + # expression's own `float_to_int` domain obligation, named here so + # the summary assertion cannot be read as counting the bind. + domain = [o for o in result.obligations + if o.kind == "float_to_int_domain"] + assert [o.status for o in domain] == ["tier3"], domain assert result.summary.tier3_runtime == 1, result.summary compiled = _compile(self._NESTED) errors = [d for d in compiled.diagnostics if d.severity == "error"] diff --git a/tests/test_module_generic_collision_1281.py b/tests/test_module_generic_collision_1281.py index 9bfa0257..b11e3007 100644 --- a/tests/test_module_generic_collision_1281.py +++ b/tests/test_module_generic_collision_1281.py @@ -402,7 +402,15 @@ def test_the_cells_type_drives_the_instantiation( @staticmethod def _diamond_registries(tmp_path: Path) -> tuple[set[str], set[str]]: - """``(_fn_sigs keys, _fn_ret_type_exprs keys)`` after the diamond.""" + """``(_fn_sigs keys, _fn_ret_type_exprs keys)`` after the diamond. + + The checker's artifacts are threaded into the generator, as + ``cmd_compile`` threads them: the call was here before them and + discarded its result, which read as a pipeline the helper was not + actually running. The two registries below are populated the same + way either way — they are built from the declarations — so this is + the fixture matching the product, not a changed measurement. + """ from vera.checker import typecheck_with_artifacts from vera.codegen.core import CodeGenerator from vera.parser import parse_to_ast @@ -421,11 +429,15 @@ def _diamond_registries(tmp_path: Path) -> tuple[set[str], set[str]]: resolved = ModuleResolver(_root=tmp_path).resolve_imports( program, main_path, ) - typecheck_with_artifacts( + _diags, arts = typecheck_with_artifacts( program, source, file=str(main_path), resolved_modules=resolved, + collect_module_artifacts=True, ) gen = CodeGenerator( source=source, file=str(main_path), resolved_modules=resolved, + expr_semantic_types=arts.expr_semantic_types, + expr_target_types=arts.expr_target_types, + module_artifacts=arts.module_artifacts, ) gen.compile_program(program) return set(gen._fn_sigs), set(gen._fn_ret_type_exprs) diff --git a/tests/test_nat_narrowing_return_differential.py b/tests/test_nat_narrowing_return_differential.py index 0641c728..b5651a70 100644 --- a/tests/test_nat_narrowing_return_differential.py +++ b/tests/test_nat_narrowing_return_differential.py @@ -33,7 +33,7 @@ import pytest -from tests.codegen_helpers import wat_calls +from tests.codegen_helpers import wat_calls, wat_fn_names from vera.codegen.api import WasmTrapError from collections.abc import Iterator @@ -2764,8 +2764,12 @@ def test_qualified_put_reaches_the_cell_past_a_user_shadow(self) -> None: assert result.ok, [d.description for d in result.diagnostics] assert wat_calls(result.wat, "vera.state_put_Int") # The user's own `put` is still emitted and still callable — it - # is simply not what the qualified site denotes. - assert "(func $put " in result.wat + # is simply not what the qualified site denotes. Exact + # membership, not a substring: `"(func $put " in wat` depends on + # a space following the symbol and would also accept a longer + # mangled name under a different emitter layout. + emitted = wat_fn_names(result.wat) + assert "put" in emitted, emitted assert _run(self._QUAL_USER_SHADOW, "go", 7) == 5 diff --git a/tests/test_prelude_adt_namespace_1277.py b/tests/test_prelude_adt_namespace_1277.py index 03f89b70..062f1f2e 100644 --- a/tests/test_prelude_adt_namespace_1277.py +++ b/tests/test_prelude_adt_namespace_1277.py @@ -518,6 +518,57 @@ def test_every_cell_is_clean_or_one_e621( ) +#: §8.4.1's two halves of the prelude's data types. Every program compiles +#: the first four, so a differently-shaped module declaration of one of them +#: "always contends"; the other four are injected only when the entry program +#: uses them, so the module's declaration "stands alone until it does". +_ALWAYS_COMPILED = ("Option", "Result", "Ordering", "UrlParts") +_DEMAND_INJECTED = ("Json", "HtmlNode", "Request", "Response") + + +def test_the_two_halves_partition_the_prelude_adts() -> None: + """Neither half may drift from `_PRELUDE_ADTS`. + + The cell below reads its expected answer off these lists, so a name + added to the battery and to neither list — or to both — would be + asserted against nothing, or against two answers. + """ + assert set(_ALWAYS_COMPILED) | set(_DEMAND_INJECTED) == set(_PRELUDE_ADTS) + assert not set(_ALWAYS_COMPILED) & set(_DEMAND_INJECTED) + + +@pytest.mark.parametrize("name", _PRELUDE_ADTS) +def test_the_alone_half_follows_the_injection_split( + tmp_path: Path, name: str, +) -> None: + """An entry that never names the type: §8.4.1 decides by which half. + + `test_every_cell_is_clean_or_one_e621` accepts either answer for these + eight cells (`set(codes) <= {"E621"}`), deliberately, so that the rail's + four-vs-four coverage split cannot return silently. That looseness is + about which names the rail COVERS; it leaves the alone half unpinned in + the other direction, and a rail that stopped reporting a differing + `Ordering` declaration in an entry that never mentions `Ordering` would + keep the whole suite green. §8.4.1 decides the question: measured + E621-with-no-exports for the four every program compiles, and + clean-with-`main`-exported for the four injected on demand. + """ + codes, exports = _acceptance_cell(tmp_path, name, demands=False) + if name in _ALWAYS_COMPILED: + assert codes == ["E621"], ( + f"{name} is compiled into every program, so a differing module " + f"declaration contends whether or not the entry names it; got " + f"{codes}" + ) + assert exports == [], f"{name}: refused, yet emitted {exports}" + else: + assert codes == [], ( + f"{name} is injected only on demand, so nothing contends when " + f"the entry never names it; got {codes}" + ) + assert exports == ["main"], exports + + @pytest.mark.parametrize("name", _PRELUDE_ADTS) def test_a_differing_module_declaration_contends_for_every_name( tmp_path: Path, name: str, diff --git a/vera/README.md b/vera/README.md index 5ab294b7..d542dbfa 100644 --- a/vera/README.md +++ b/vera/README.md @@ -753,7 +753,7 @@ The `ERROR_CODES` dict in `errors.py` maps every code to a short description (16 ## Test Suite -Testing spans a **pytest suite** of 11,960 tests across 175 files — compiler-internals unit tests plus a **conformance suite** (244 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. +Testing spans a **pytest suite** of 11,969 tests across 175 files — compiler-internals unit tests plus a **conformance suite** (244 programs in `tests/conformance/` validating every language feature against the spec) and **example programs** (42 end-to-end demos). The conformance suite is the definitive specification artifact — most programs target a single feature, though some (slot references, match, contracts) span several, and each serves as a minimal working example. See **[TESTING.md](../TESTING.md)** for the comprehensive testing reference -- test file table, conformance suite details, compiler code coverage, language feature coverage, helper conventions, validation scripts, CI pipeline, and guidelines for adding tests. diff --git a/vera/codegen/modules.py b/vera/codegen/modules.py index b357ce2d..605cda4f 100644 --- a/vera/codegen/modules.py +++ b/vera/codegen/modules.py @@ -955,9 +955,15 @@ def _collect_namespace_fn_names(self, program: ast.Program) -> None: the second is after the prelude pass, once ``_prelude_fn_names`` is populated, because the prelude's combinators are visible in every namespace and the first call cannot know them yet. The derivation is - pure, so the second call simply replaces the first's answer; the - ambiguity half is identical either way (a prelude name is imported - from nowhere). Route three of #1299 (a ``forall`` parent's + pure, so the second call simply replaces the first's answer. The + ambiguity half is NOT identical either way: the combinators are + overridable rather than reserved, so two dependencies that each + export ``option_map`` are ambiguous under the empty prelude and are + not under the populated one (:func:`~vera.monomorphize + .namespace_fn_names` records the measurement). The E608 rail below + reads the FIRST, prelude-empty answer, because ``_register_modules`` + runs between the two calls — so the ORDERING is load-bearing and + neither call may move. Route three of #1299 (a ``forall`` parent's ``where`` helper) involves no imports at all, so the entry program needs its set whether or not any module exists. """ From 84c6338511db337495e446c0ef71cebb1699333a Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Sat, 15 Aug 2026 07:38:56 +0100 Subject: [PATCH 67/69] Track #1332 and give the two json_serde sections one owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `#1332` is the false Tier-1 the §6.4.3 probe turned up while the release PR's unguarded-residual list was being corrected: a `@Nat` tuple component's `nat_bind` is recorded `verified` at construction under `requires(true)`, so `vera verify` exits clean, and `vera run` then traps on `f(-1)` at the destructure guard that re-checks the component. The return-position control isolates it — the identical construction as a function result is correctly `violated`/E503 — so the two paths reach different verdicts for the same value. * **`KNOWN_ISSUES.md`** gains its Bugs row, newest first, carrying the measurement and the isolation rather than a summary of the title. * **`ROADMAP.md`**'s v0.1.13 queue carries sixteen and LEADS with it: every other open bug is loud, and a verifier reporting a proof it does not have is the one failure a reader cannot see. The queue and the Bugs table are set-equal at sixteen; `--check-bug-issues` reports only the eighteen issues this PR fixes, which close on merge, and no row that is not an open bug. * **`vera/wasm/json_serde.py`**: `_NON_FINITE_NAMES` moves above both sections that read it (PR review). The accept-domain gate was resolving the name through a constant defined inside the canonical serialization section beside its private `_non_finite_message`, so the dependency was invisible to a reader working on either half and relocating either section would have broken the other — and the accept domain is separately hand-mirrored into `vera/browser/runtime.mjs`. Sharing the constant is right (one spelling for both halves, so they cannot drift); its placement was not. Pure relocation: both call sites resolve identically, and the `repr` key is documented as deliberate, since a NaN is not equal to itself and cannot key a dict by value. Skip-changelog: constant relocated within one module; no behaviour change. Co-Authored-By: Claude --- KNOWN_ISSUES.md | 1 + ROADMAP.md | 5 +++-- vera/wasm/json_serde.py | 15 +++++++++++++-- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index d172ed97..20d8d03e 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -8,6 +8,7 @@ Defects in shipped compiler, runtime, or tooling behaviour — this table matche | Bug | Issue | |-----|-------| +| A `@Nat` tuple component's narrowing obligation is reported PROVED at construction while the compiled program traps on the same value — a false Tier-1, not a missing guard. `let @Tuple = Tuple(@Int.0, 5);` inside `f(@Int -> @Int)` under `requires(true)` records the construction's `nat_bind` as `verified` with no diagnostic, so `vera verify` exits clean claiming the narrowing is discharged; `vera run` then traps on `f(-1)` (`Reached 'unreachable'`) at the destructure guard that re-checks the component, while `f(7)` returns 7. A proved obligation whose guard fires is the contradiction: either the proof is wrong or the trap is unreachable, and the runtime says which. The return-position control isolates it to the `let`-with-declared-tuple-type path — the identical construction as a function result (`mk(@Int -> @Tuple) { Tuple(@Int.0, 5) }`) is correctly `violated`/E503 under the same `requires(true)`. Soundness-class, and the wrong direction for a verifier: the program that reads verify-clean is the one that traps, so nothing downstream is warned. Measured while probing spec §6.4.3's unguarded-residual list, which the same probe corrected — a tuple component at construction carries no runtime guard at all, and the destructure guard that catches this one fires only because the program happens to take the tuple apart. Fix direction: find what discharges the construction obligation under a declared `let` tuple type and does not discharge it in return position; the two paths must reach the same verdict for the same value, and the return path's is the correct one. | [#1332](https://github.com/aallan/vera/issues/1332) | | A user `data` declaration taking a built-in container's name compiles at the built-in's width. `_type_expr_to_wasm_type` consults the `Array` / `Map` / `Set` / `Decimal` / `Future` branches **before** `_adt_layouts`, so `data Array { Mk(Int) }` is measured as the built-in's `i32_pair` rather than the ADT pointer `i32`. The program is check-green; codegen then refuses the function matching on it with a located E602 ("a scrutinee whose representation is a (ptr, len) pair"), drops its callers, and emits a module with **no exports at all** — `vera run` reports `Available exports: (none)` where a fresh-name control prints its value. Loud, not a wrong answer. Only `Array` misbehaves: `Map`, `Set` and `Decimal` already answer `i32`, the ADT pointer's own width, so they are inert by width-luck — the same coincidence that kept most of [#1309](https://github.com/aallan/vera/issues/1309)'s alias cases hidden. Pre-existing: v0.1.11 carries the identical ordering, and #1309 moved only the *alias* branch above these. The fix needs at least three derivations to agree and two are not enough — `_type_expr_to_wasm_type` (`vera/codegen/core.py`) and `_canonical_wasm_type` (`vera/wasm/inference.py`) were both corrected and the program still lost its exports, so a third site is unlocated. Note the trap between them: the emitter mixin does not own `_adt_layouts` but `_adt_type_names`, so a `getattr(self, "_adt_layouts", {})` there is a silently disabled shadow rule rather than a failure. | [#1331](https://github.com/aallan/vera/issues/1331) | | The nightly stress workflow never exercises `TestHostHandleReclamation573` — its 10 stress-marked instances are deselected from the per-PR suite (`-m 'not stress'` addopts) and `.github/workflows/nightly-stress.yml` runs `pytest -v -m stress tests/test_stress.py`, file-scoped, so no automated lane runs the #573/#575/#576/#706 GC-reclamation battery; the class comment's "Run via `pytest -m stress` or nightly CI" states the unmet intent. Run `pytest -m stress` manually until the workflow adds the file or the comment is re-scoped. | [#1328](https://github.com/aallan/vera/issues/1328) | | Monomorphization's Vera-level type namer has no `IndexExpr` arm, so a generic argument that is an array index drops its caller. `idg(@Array.0[1])` — a `forall` identity called on an element of a local `Array` — is `vera check`-green and then dies at `vera run` with `[E602] Function 'main' body contains unsupported FnCall: call target 'idg$Int' not registered in this module`, leaving `Available exports: (none)`: instantiation discovery (`Monomorphizer._infer_vera_type_name`) answers nothing for the index expression and falls to the phantom-var `Bool` default, while the WASM call-rewrite consultor (`InferenceMixin._infer_vera_type`), which DOES have an `IndexExpr` arm, names `idg$Int` at the call — the two consultors disagreeing, which is the clone-name agreement contract's (#772) failure mode. Loud, never a wrong answer. Measured during #1286's review sweep and unchanged by its fix, which closed the same consultor-parallelism gap for `Block`, `MatchExpr`, `IfExpr` and `HandleExpr` — each an exact one-line mirror of its twin — and deliberately left this one: the rewrite's arm delegates to `_infer_index_element_type`, which resolves chained indexing, type aliases and representation-transparent `Future` payloads against codegen tables the monomorphizer does not carry, so a partial mirror would answer differently from the rewrite for those cases, replacing a shape where both consultors say "unknown" with one where they disagree. Fix direction: lift the element-type derivation into one helper both consultors call, rather than a second partial copy that can drift from the first — the shape #1286 argues for throughout. | [#1327](https://github.com/aallan/vera/issues/1327) | diff --git a/ROADMAP.md b/ROADMAP.md index e2eef446..ab94f3c2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -16,12 +16,13 @@ Ordering derives from the design principles ([DESIGN.md](DESIGN.md)): verificati ## The v0.1.13 burndown -*Fifteen open bugs, driven to zero.* +*Sixteen open bugs, driven to zero.* -A bug class outranks stage work, so the next release takes the open `bug`-labelled set as its queue. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) carries each row's full account and stays the one place the detail lives; this table is the order of attack. +A bug class outranks stage work, so the next release takes the open `bug`-labelled set as its queue. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) carries each row's full account and stays the one place the detail lives; this table is the order of attack. The soundness row leads it: every other bug is loud, and a verifier that reports a proof it does not have is the one failure a reader cannot see. | Issue | What | |---|---| +| [#1332](https://github.com/aallan/vera/issues/1332) | **Soundness.** A `@Nat` tuple component's narrowing verifies as proved at construction while the compiled program traps on that value. | | [#1331](https://github.com/aallan/vera/issues/1331) | A user `data` declaration named after a built-in container compiles at the container's width, so a check-green program loses its exports to an E602 refusal. | | [#1315](https://github.com/aallan/vera/issues/1315) | The checker accepts a constructor pattern over a container ADT that has no constructors. | | [#1320](https://github.com/aallan/vera/issues/1320) | The checker admits literal patterns over scrutinees that can never match them — the general disease behind #1315. | diff --git a/vera/wasm/json_serde.py b/vera/wasm/json_serde.py index 7770978b..3d4f4279 100644 --- a/vera/wasm/json_serde.py +++ b/vera/wasm/json_serde.py @@ -74,6 +74,16 @@ _TAG_JARRAY = 4 _TAG_JOBJECT = 5 +# The three non-finite doubles under the names JavaScript spells them with. +# BOTH sections below read it — the accept domain to name the value it is +# refusing, canonical serialization to name the one it cannot render — so it +# sits above them rather than inside either: a private constant in one section +# consulted from the other is a dependency invisible to a reader working on +# that half, and the accept domain is separately hand-mirrored into +# ``vera/browser/runtime.mjs``. Keyed on ``repr`` deliberately: a NaN is not +# equal to itself, so a dict keyed on the float VALUE cannot retrieve it. +_NON_FINITE_NAMES = {"nan": "NaN", "inf": "Infinity", "-inf": "-Infinity"} + # --------------------------------------------------------------------------- # json_parse's accept domain (spec §9.7.1) @@ -508,8 +518,9 @@ def read_json( # ensure_ascii=False)`` and ``JSON.stringify(s)`` already agree byte for # byte over the escape table, so the one function that is right is the # one that gets called. - -_NON_FINITE_NAMES = {"nan": "NaN", "inf": "Infinity", "-inf": "-Infinity"} +# +# ``_NON_FINITE_NAMES`` — read by ``format_json_number`` below — is defined +# above the accept-domain section, the other half that reads it. def _non_finite_message(name: str) -> str: From 91ad93033b6a538c0e6f3e855ae21150b095ecb7 Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Sat, 15 Aug 2026 08:13:29 +0100 Subject: [PATCH 68/69] Reconcile the release round's own edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four findings from the review of the round commits, each verified: the KNOWN_ISSUES universal statically-proven claim gains its measured 1332 exception; the ROADMAP queue intro drops the every-other-bug-is- loud clause its own 1312 row falsifies; the conformance decode-failure branch names the entry's file instead of calling .name on a str; and the CHANGELOG's stale pre-reconciliation handler-ownership bullet is removed — the keeper is the one reading "closed by 1299 below", the removed twin read "tracked as 1299". Skip-changelog: reconciliation edits inside the 0.1.12 section and docs Co-Authored-By: Claude --- CHANGELOG.md | 2 -- KNOWN_ISSUES.md | 2 +- ROADMAP.md | 2 +- tests/test_conformance.py | 2 +- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 808e690a..68a944f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,8 +58,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **The prelude's declaration-index block no longer depends on what the main file declares** ([#1287](https://github.com/aallan/vera/issues/1287)). `_stamp_decl_order` guarded its PRELUDE write on `_decl_order`, the ACTIVE (main-file) namespace — but `_prelude_decl_order` is not a namespace: `_module_alias_scope` builds every module's index space as `{**prelude, **module_own}`, so it is the base layer under all of them and its contents are a fact about what `inject_prelude` laid down. A main-file `type Option = Int` is accepted (§8.4.1 again) and, being an alias rather than a `data`, does not suppress the prelude's own `data Option`, so the guard fired on the prelude stamp: `Option` was left out of the block entirely, and every later prelude declaration shifted one place earlier because the skipped stamp never advanced the counter. Inside a module namespace the prelude's `Option` then reached `AliasEnv.data_types` at `_BUILTIN_DECL_INDEX` — below `_PRELUDE_DECL_BASE`, so ordered ahead of every other prelude declaration rather than among them — which is exactly the cross-namespace leak `_decl_order` and `_module_decl_order` were split apart to prevent. Latent at emission: that map changes a rendering only for `Decimal` and the single `REMOVED_ALIASES` entry `Float`, and no prelude ADT is either, so no WAT moves; the defect is the wrong value reaching the consumer. The prelude write is now unconditional and the ACTIVE space still takes the main file's stamp, so the shadow keeps winning its own namespace. Pinned as an invariance — the same program with and without the shadowing alias must stamp an identical prelude block — with the main-namespace control that a fix stamping `_decl_order` unconditionally would fail. -- **A user-defined `fn get` / `fn put` is no longer hijacked by an enclosing handler** ([#1284](https://github.com/aallan/vera/issues/1284)). Three sites answered "does this `get` mean the user's declaration or the effect operation?" independently. The checker answers user-fn-first — `_check_call_with_args` looks a bare name up as a function before it looks it up as an operation, so a declaration named `get` owns every bare `get(...)` in its scope, which an arity or argument-type error at such a call site proves by reporting the *user's* signature (E201/E202). Codegen answered twice more: the declared-effect row in `vera/codegen/functions.py` withheld the intrinsic when `_fn_sigs` already owned the name, and the handler expression in `vera/wasm/calls_handlers.py` installed `get`/`put` unconditionally. From `vera check`-green source that produced, depending on the nesting shape, a **silently wrong value** (`nat_to_int(get(3))` under `handle[State](@Int = 5)` returned the cell's 5 for the function's 4, and the argument was not even emitted), a **module WASM validation rejects** (a `@Bool`-returning user `get` took `state_get_Int`'s i64 into an `i32` position; different-family nesting took the *enclosing* cell's getter at the wrong width), or a **spurious `[E602]`** in which the #1233 unaddressable-cell gate refused `main` outright, naming "a bare or qualified State operation `get`" the program never contained. The repair is one predicate, `vera.slots.bare_call_denotes_user_fn`, stating the checker's rule once and consumed by the bare-call dispatch in `vera/wasm/calls.py` (which now gates the clause-inline registry, the host-cell intrinsics and the addressability gate together), by the three bare-`FnCall` result-type inference sites, and by the monomorphizer's discovery walk — each passing its own name table, so the sites cannot answer differently about the table they share. Gating the *dispatch* rather than the *registries* is what makes it correct rather than merely consistent: the registries record which cell an op name reaches, which is true whatever the program's declarations are called, and withholding an entry answered both questions with one table. That is why the gate-only fix measured during PR #1283's review turned the loud skip into a differently-broken module, and why it also cost the qualified spelling its cell — `State.put(5)` in a function that also declares `fn put` compiled to `call $vera.put` and failed to link, which now lowers to the intrinsic the checker always meant. Discovery's `MonoContext.fn_names` moves to the same lookup-time question, so a `get(())` fixing a generic's type variable under a handler names the clone the rewrite emits. All 256 conformance and example programs emit byte-identical WAT. **The `W002` async-commutativity warning was the same defect in the checker's own file** and is corrected with them: `_collect_expr_effects` asked `lookup_effect_op` before the scoped function lookup — the last op-first consumer — so a user function named after an operation contributed the *operation's* parent effect to the commutativity analysis instead of its own declared row, wrong in both directions. A **pure** `fn get`, in a program containing no `State` at all, drew `async argument performs State effects`; a `fn get` that performs `IO`, under a row naming `Http` first, drew **no warning**, because the walk bound the name to `Http.get`, which is inside the commutative whitelist, and silently withheld the eager-evaluation warning the program is owed. Both are pinned with rename controls — the byte-identical program with the helper called `gett` / `fetch` was correct throughout — beside a control that an unshadowed bare `get(())` under a `State` row still warns, so the fix cannot degenerate into never reporting `State`. The walk's comment claiming it resolves "like the call checker above" is now true. One caveat the predicate does not close: codegen's name table is not scope-accurate, so a name the call site cannot see still answers "user-owned" there — an invisible import by either of two routes, or a `where` helper of a `forall` parent, which unlike a non-generic parent's keeps a bare key. From check-green source that is a silent wrong value, a load failure, or an E-code-less `unknown func` depending on the route, tracked as [#1299](https://github.com/aallan/vera/issues/1299); a property of the table rather than of the rule. - - **`new(State)` reads the cell its contract names** ([#1285](https://github.com/aallan/vera/issues/1285)). `old(State)` has been keyed on the resolved cell family since [#1205](https://github.com/aallan/vera/issues/1205)/[#1209](https://github.com/aallan/vera/issues/1209); `new(State)` read the name-keyed `_effect_ops["get"]`, which holds whichever `State` the effect row registered first. Under a single-`State` row the two keyings coincide, which is why the corpus agreed; under a multi-`State` row the two sides of one `ensures` clause read different cells. `effects(, State>)` with `ensures(new(State) == false)` was check-green *and* verify-green, emitted `state_get_Int`'s i64 into the Bool comparison's `i32.eq`, and died at load with wasmtime's raw `type mismatch: expected i32, found i64`. The type mismatch is the symptom rather than the defect: where both cells share a machine width — `State` beside `State` — the module loaded and answered about the other cell, so `ensures(new(State) == old(State))` on a function that writes neither was refuted at runtime on a contract the verifier had discharged. Codegen now carries a family→getter registry populated at the declared-row registration site from the per-family `CellNames` it already computes, and `_translate_new_expr` keys on `_state_effect_family` exactly as `_translate_old_expr` does. A bare `get(())` names no family and so is right to keep reading the name-keyed registry, source-order-first-wins; a contract names one and must not. - **`decimal_from_string` ignores one stated whitespace set, and a leading byte-order mark survives the browser boundary** ([#856](https://github.com/aallan/vera/issues/856) review). §9.7.2 said the grammar is applied "after ignoring surrounding whitespace" and that the accepted domain is defined by the grammar "rather than inherited from whatever the host library parses" — but the whitespace half *was* inherited, from `str.strip` on the reference host and `String.prototype.trim` in the browser, and those two sets differ in both directions. Measured through identical module bytes: `U+001C`–`U+001F` and `U+0085` around a decimal were `Some` natively and `None` in the browser, `U+FEFF` was `None` natively and `Some` in the browser, and `U+00A0` with the Unicode space separators were accepted by both for reasons neither specification names. §9.7.2 now states the set, and it is the one the language already had: the six code points `is_whitespace` names. **The `U+FEFF` half turned out not to be about trimming at all** — `new TextDecoder('utf-8')` defaults to `ignoreBOM: false`, whose meaning is the reverse of its name, so the browser's `readString` **removed** a byte-order mark from the front of every string crossing into a host binding. `IO.print("\u{FEFF}x")` printed `x`, `json_parse` accepted a BOM-prefixed document the reference host refuses, and `md_parse` dropped the character from its text. The decoder now passes it through, matching `safe_utf8_decode`, which never stripped one. The exponent bound `|exp| <= 999999` is unchanged in force and in value; only its keyword is, from a lowercase "must" to the RFC 2119 MUST it was always enforced as. diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 20d8d03e..d99e3fa3 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -35,7 +35,7 @@ Things Vera cannot do yet, as distinct from defects in what it claims to do. | An ADT `decreases` measure is runtime-ranked only when its type's reachable field structure is fully concrete: a parameterized measure (`List`), or a concrete type whose recursion rides through a parameterized field, gets no runtime guard (never a wrong one — the registered generic layout does not describe concrete construction), staying statically checked and Tier-3-disclosed. Per-instantiation `$dec_size_` helpers (the `$eq_` pattern) close it. | [#1177](https://github.com/aallan/vera/issues/1177) | | Tier 2 verification (Z3 guided by `assert`/lemma hints) is specified in §6.3.2 but not implemented, so contracts that need hints fall to Tier 3 runtime checks. Per-monomorphization verification (#732) has since landed; full Tier 2 (hint-guided) stays on the Milestone 4 horizon. | [#427](https://github.com/aallan/vera/issues/427) | | `data invariant(...)` clauses (spec §2.6, §6.2.3) are not implemented — every documented form fails with E130 because the slot environment for the invariant predicate isn't wired up. Refinement types (`{ @T \| predicate }`, §2.6) are the working alternative until this lands. | [#686](https://github.com/aallan/vera/issues/686) | -| **Statically**, every narrowing *binding site* is obligated: a provably-negative value is an E503 error, and an untranslatable narrowing at an unguarded site is an E504 warning — so a program whose narrowings are all statically PROVEN stores no negative `@Nat` at these sites. `vera verify` exits 0 on the E504 warnings too, so verify-clean is the weaker property: a disclosed narrowing is one the static proof did not reach, and at an unguarded site nothing else does either. **At runtime** (an unverified `vera compile`/`run`), the guard covers every concrete *direct* binding site (a nested constructor sub-pattern is the exception, #765 below), generic function-formal calls, and the string/markup builtin `@Nat` parameters (`string_repeat`, `string_pad_start`/`_end`, `string_from_char_code`, `md_has_heading`), but four statically-obligated sites stay unguarded and store a negative silently: the effect-operation argument of a USER-declared effect — the built-in `State` `put`/`resume` boundaries and the built-in `Exn` `throw` payload are obligated and codegen-guarded, the latter with the #1203 sign/widening pair on unrefined payloads and, on a refined payload, the §2.6.5 predicate guard (its lowered check includes the base's range) plus the independent `@Nat`-to-`@Int` widening guard ([#1268](https://github.com/aallan/vera/issues/1268)) — (codegen's `_effect_ops` carries only the dispatch target for the rest), the generic-instantiated constructor field (constructor layouts carry no per-field `@Nat` mono metadata), the `nat_to_int`/`nat_to_string` conversion builtins (special-cased before the `_fn_nat_params` guard loop), and a **tuple component at construction** (the built-in variadic `Tuple` carrier has no per-field metadata either) — no runtime guard is emitted at those four sites; the unguarded residual is disclosed statically as an E504 warning instead. (The static obligation now also covers the function **return** position — an `@Int` value narrowing into a `@Nat` return is an E503 obligation backed by a codegen return guard, #758 — and the value-position constructor component, which is both statically obligated and runtime-guarded where the field is CONCRETE. The value-position *tuple* component is the fourth unguarded site above: measured `tier3_unguarded`/E504 with no guard emitted, at construction and at the function boundary alike — a tuple that is destructured is checked at the destructure, one that is only returned or passed on is not checked at all.) #820 threaded the per-component target-type table into codegen (the metadata these narrowing guards need); the generic-field and effect-op sites are now unblocked for wiring, though the narrowing direction is not yet guarded here. | [#754](https://github.com/aallan/vera/issues/754), [#757](https://github.com/aallan/vera/issues/757) | +| **Statically**, every narrowing *binding site* is obligated: a provably-negative value is an E503 error, and an untranslatable narrowing at an unguarded site is an E504 warning — so a program whose narrowings are all statically PROVEN stores no negative `@Nat` at these sites — with one measured exception, the declared-tuple `let` construction site, where the obligation is recorded proved yet the negative reaches the later destructure guard ([#1332](https://github.com/aallan/vera/issues/1332), the queue's soundness item). `vera verify` exits 0 on the E504 warnings too, so verify-clean is the weaker property: a disclosed narrowing is one the static proof did not reach, and at an unguarded site nothing else does either. **At runtime** (an unverified `vera compile`/`run`), the guard covers every concrete *direct* binding site (a nested constructor sub-pattern is the exception, #765 below), generic function-formal calls, and the string/markup builtin `@Nat` parameters (`string_repeat`, `string_pad_start`/`_end`, `string_from_char_code`, `md_has_heading`), but four statically-obligated sites stay unguarded and store a negative silently: the effect-operation argument of a USER-declared effect — the built-in `State` `put`/`resume` boundaries and the built-in `Exn` `throw` payload are obligated and codegen-guarded, the latter with the #1203 sign/widening pair on unrefined payloads and, on a refined payload, the §2.6.5 predicate guard (its lowered check includes the base's range) plus the independent `@Nat`-to-`@Int` widening guard ([#1268](https://github.com/aallan/vera/issues/1268)) — (codegen's `_effect_ops` carries only the dispatch target for the rest), the generic-instantiated constructor field (constructor layouts carry no per-field `@Nat` mono metadata), the `nat_to_int`/`nat_to_string` conversion builtins (special-cased before the `_fn_nat_params` guard loop), and a **tuple component at construction** (the built-in variadic `Tuple` carrier has no per-field metadata either) — no runtime guard is emitted at those four sites; the unguarded residual is disclosed statically as an E504 warning instead. (The static obligation now also covers the function **return** position — an `@Int` value narrowing into a `@Nat` return is an E503 obligation backed by a codegen return guard, #758 — and the value-position constructor component, which is both statically obligated and runtime-guarded where the field is CONCRETE. The value-position *tuple* component is the fourth unguarded site above: measured `tier3_unguarded`/E504 with no guard emitted, at construction and at the function boundary alike — a tuple that is destructured is checked at the destructure, one that is only returned or passed on is not checked at all.) #820 threaded the per-component target-type table into codegen (the metadata these narrowing guards need); the generic-field and effect-op sites are now unblocked for wiring, though the narrowing direction is not yet guarded here. | [#754](https://github.com/aallan/vera/issues/754), [#757](https://github.com/aallan/vera/issues/757) | | A refinement / `@Nat` narrowing bound in a **nested** constructor sub-pattern (`Some(Some(@PosInt))` on `Option>`) is statically obligated — a `vera verify`-clean program is sound; a bad nested refined narrowing is `E505`, a bad nested `@Nat` narrowing is `E503` (#763) — but the runtime guard covers only the `@Nat` nested bind (verified by run probes: a negative payload traps in both direct and closure positions) — the *refined* nested bind is not guarded, so an unverified compile doesn't trap on a violating nested refined payload; the verifier discloses that residual as `tier3_unguarded`/E506 | [#765](https://github.com/aallan/vera/issues/765) | | A refinement whose base carries a **non-plain type argument** (`{ @Array<{ @Int \| ... }> \| array_length(...) > 0 }`, an `Array` base) gets **no runtime guard at any boundary** — `_refinement_guard_parts` cannot spell the binder slot for such a base, so named-function and closure parameters and returns alike leave the predicate unchecked at run time. The verifier discloses this honestly (`tier3_unguarded`, E506, excluded from runtime-checked totals — `_refined_boundary_codegen_guardable` mirrors the codegen bail, KEEP IN SYNC); the guard itself is the open work. Same disclosure family as #754 / #757 / #765. | [#1036](https://github.com/aallan/vera/issues/1036) | | Contracts that depend on a `handle[...]` expression's value fall to Tier 3 — the verifier does not model the `handle[...]` expression's value or handler-clause state, so an `ensures` over a handle result is runtime-checked rather than statically proved. (Primitive-op and binding obligations *inside* the handle body walk at enclosing-scope precision and can prove Tier-1; a clause body's slot-dependent obligations are fresh-scope Tier-3, while a manifest violation there — `5 / 0` — stays a loud compile error.) | [#439](https://github.com/aallan/vera/issues/439) | diff --git a/ROADMAP.md b/ROADMAP.md index ab94f3c2..d838fe6a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,7 +18,7 @@ Ordering derives from the design principles ([DESIGN.md](DESIGN.md)): verificati *Sixteen open bugs, driven to zero.* -A bug class outranks stage work, so the next release takes the open `bug`-labelled set as its queue. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) carries each row's full account and stays the one place the detail lives; this table is the order of attack. The soundness row leads it: every other bug is loud, and a verifier that reports a proof it does not have is the one failure a reader cannot see. +A bug class outranks stage work, so the next release takes the open `bug`-labelled set as its queue. [KNOWN_ISSUES.md](KNOWN_ISSUES.md) carries each row's full account and stays the one place the detail lives; this table is the order of attack. The soundness row leads it: a verifier that reports a proof it does not have is the one failure a reader cannot see. | Issue | What | |---|---| diff --git a/tests/test_conformance.py b/tests/test_conformance.py index 471bf9db..b2b2f438 100644 --- a/tests/test_conformance.py +++ b/tests/test_conformance.py @@ -106,7 +106,7 @@ def test_check(self, entry: dict) -> None: # streams this arrives as a bare decode error about an # empty document (#1330 review). raise AssertionError( - f"{path.name}: {stage} --json produced no JSON envelope " + f"{entry['file']}: {stage} --json produced no JSON envelope " f"({exc}).\nstdout:\n{result.stdout}\n" f"stderr:\n{result.stderr}" ) from exc From 23bc160bcc5c818882040a0e33a2a2f52a289edb Mon Sep 17 00:00:00 2001 From: Alasdair Allan Date: Sat, 15 Aug 2026 08:21:45 +0100 Subject: [PATCH 69/69] Scope the tuple-component clauses to what each layer actually does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review's last catch: the four-site E504 disclosure claim is scoped with its 1332 exception (that path discloses nothing — the verifier records the obligation proved), and the returned-tuple sentence now separates the missing RUNTIME check from the static obligation the return position still carries (the E503 control on the 1332 row). Skip-changelog: KNOWN_ISSUES row wording Co-Authored-By: Claude --- KNOWN_ISSUES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index d99e3fa3..a5fe3577 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -35,7 +35,7 @@ Things Vera cannot do yet, as distinct from defects in what it claims to do. | An ADT `decreases` measure is runtime-ranked only when its type's reachable field structure is fully concrete: a parameterized measure (`List`), or a concrete type whose recursion rides through a parameterized field, gets no runtime guard (never a wrong one — the registered generic layout does not describe concrete construction), staying statically checked and Tier-3-disclosed. Per-instantiation `$dec_size_` helpers (the `$eq_` pattern) close it. | [#1177](https://github.com/aallan/vera/issues/1177) | | Tier 2 verification (Z3 guided by `assert`/lemma hints) is specified in §6.3.2 but not implemented, so contracts that need hints fall to Tier 3 runtime checks. Per-monomorphization verification (#732) has since landed; full Tier 2 (hint-guided) stays on the Milestone 4 horizon. | [#427](https://github.com/aallan/vera/issues/427) | | `data invariant(...)` clauses (spec §2.6, §6.2.3) are not implemented — every documented form fails with E130 because the slot environment for the invariant predicate isn't wired up. Refinement types (`{ @T \| predicate }`, §2.6) are the working alternative until this lands. | [#686](https://github.com/aallan/vera/issues/686) | -| **Statically**, every narrowing *binding site* is obligated: a provably-negative value is an E503 error, and an untranslatable narrowing at an unguarded site is an E504 warning — so a program whose narrowings are all statically PROVEN stores no negative `@Nat` at these sites — with one measured exception, the declared-tuple `let` construction site, where the obligation is recorded proved yet the negative reaches the later destructure guard ([#1332](https://github.com/aallan/vera/issues/1332), the queue's soundness item). `vera verify` exits 0 on the E504 warnings too, so verify-clean is the weaker property: a disclosed narrowing is one the static proof did not reach, and at an unguarded site nothing else does either. **At runtime** (an unverified `vera compile`/`run`), the guard covers every concrete *direct* binding site (a nested constructor sub-pattern is the exception, #765 below), generic function-formal calls, and the string/markup builtin `@Nat` parameters (`string_repeat`, `string_pad_start`/`_end`, `string_from_char_code`, `md_has_heading`), but four statically-obligated sites stay unguarded and store a negative silently: the effect-operation argument of a USER-declared effect — the built-in `State` `put`/`resume` boundaries and the built-in `Exn` `throw` payload are obligated and codegen-guarded, the latter with the #1203 sign/widening pair on unrefined payloads and, on a refined payload, the §2.6.5 predicate guard (its lowered check includes the base's range) plus the independent `@Nat`-to-`@Int` widening guard ([#1268](https://github.com/aallan/vera/issues/1268)) — (codegen's `_effect_ops` carries only the dispatch target for the rest), the generic-instantiated constructor field (constructor layouts carry no per-field `@Nat` mono metadata), the `nat_to_int`/`nat_to_string` conversion builtins (special-cased before the `_fn_nat_params` guard loop), and a **tuple component at construction** (the built-in variadic `Tuple` carrier has no per-field metadata either) — no runtime guard is emitted at those four sites; the unguarded residual is disclosed statically as an E504 warning instead. (The static obligation now also covers the function **return** position — an `@Int` value narrowing into a `@Nat` return is an E503 obligation backed by a codegen return guard, #758 — and the value-position constructor component, which is both statically obligated and runtime-guarded where the field is CONCRETE. The value-position *tuple* component is the fourth unguarded site above: measured `tier3_unguarded`/E504 with no guard emitted, at construction and at the function boundary alike — a tuple that is destructured is checked at the destructure, one that is only returned or passed on is not checked at all.) #820 threaded the per-component target-type table into codegen (the metadata these narrowing guards need); the generic-field and effect-op sites are now unblocked for wiring, though the narrowing direction is not yet guarded here. | [#754](https://github.com/aallan/vera/issues/754), [#757](https://github.com/aallan/vera/issues/757) | +| **Statically**, every narrowing *binding site* is obligated: a provably-negative value is an E503 error, and an untranslatable narrowing at an unguarded site is an E504 warning — so a program whose narrowings are all statically PROVEN stores no negative `@Nat` at these sites — with one measured exception, the declared-tuple `let` construction site, where the obligation is recorded proved yet the negative reaches the later destructure guard ([#1332](https://github.com/aallan/vera/issues/1332), the queue's soundness item). `vera verify` exits 0 on the E504 warnings too, so verify-clean is the weaker property: a disclosed narrowing is one the static proof did not reach, and at an unguarded site nothing else does either. **At runtime** (an unverified `vera compile`/`run`), the guard covers every concrete *direct* binding site (a nested constructor sub-pattern is the exception, #765 below), generic function-formal calls, and the string/markup builtin `@Nat` parameters (`string_repeat`, `string_pad_start`/`_end`, `string_from_char_code`, `md_has_heading`), but four statically-obligated sites stay unguarded and store a negative silently: the effect-operation argument of a USER-declared effect — the built-in `State` `put`/`resume` boundaries and the built-in `Exn` `throw` payload are obligated and codegen-guarded, the latter with the #1203 sign/widening pair on unrefined payloads and, on a refined payload, the §2.6.5 predicate guard (its lowered check includes the base's range) plus the independent `@Nat`-to-`@Int` widening guard ([#1268](https://github.com/aallan/vera/issues/1268)) — (codegen's `_effect_ops` carries only the dispatch target for the rest), the generic-instantiated constructor field (constructor layouts carry no per-field `@Nat` mono metadata), the `nat_to_int`/`nat_to_string` conversion builtins (special-cased before the `_fn_nat_params` guard loop), and a **tuple component at construction** (the built-in variadic `Tuple` carrier has no per-field metadata either) — no runtime guard is emitted at those four sites; the unguarded residual is disclosed statically as an E504 warning instead — except on the [#1332](https://github.com/aallan/vera/issues/1332) path above, where the verifier currently records the obligation proved and discloses nothing. (The static obligation now also covers the function **return** position — an `@Int` value narrowing into a `@Nat` return is an E503 obligation backed by a codegen return guard, #758 — and the value-position constructor component, which is both statically obligated and runtime-guarded where the field is CONCRETE. The value-position *tuple* component is the fourth unguarded site above: measured `tier3_unguarded`/E504 with no guard emitted where the narrowing is untranslatable, at construction and at the function boundary alike — a tuple that is destructured is checked at the destructure, while one that is only returned or passed on gets no RUNTIME check; statically the positions still obligate, which is why the provably-negative return control on the #1332 row above reports E503.) #820 threaded the per-component target-type table into codegen (the metadata these narrowing guards need); the generic-field and effect-op sites are now unblocked for wiring, though the narrowing direction is not yet guarded here. | [#754](https://github.com/aallan/vera/issues/754), [#757](https://github.com/aallan/vera/issues/757) | | A refinement / `@Nat` narrowing bound in a **nested** constructor sub-pattern (`Some(Some(@PosInt))` on `Option>`) is statically obligated — a `vera verify`-clean program is sound; a bad nested refined narrowing is `E505`, a bad nested `@Nat` narrowing is `E503` (#763) — but the runtime guard covers only the `@Nat` nested bind (verified by run probes: a negative payload traps in both direct and closure positions) — the *refined* nested bind is not guarded, so an unverified compile doesn't trap on a violating nested refined payload; the verifier discloses that residual as `tier3_unguarded`/E506 | [#765](https://github.com/aallan/vera/issues/765) | | A refinement whose base carries a **non-plain type argument** (`{ @Array<{ @Int \| ... }> \| array_length(...) > 0 }`, an `Array` base) gets **no runtime guard at any boundary** — `_refinement_guard_parts` cannot spell the binder slot for such a base, so named-function and closure parameters and returns alike leave the predicate unchecked at run time. The verifier discloses this honestly (`tier3_unguarded`, E506, excluded from runtime-checked totals — `_refined_boundary_codegen_guardable` mirrors the codegen bail, KEEP IN SYNC); the guard itself is the open work. Same disclosure family as #754 / #757 / #765. | [#1036](https://github.com/aallan/vera/issues/1036) | | Contracts that depend on a `handle[...]` expression's value fall to Tier 3 — the verifier does not model the `handle[...]` expression's value or handler-clause state, so an `ensures` over a handle result is runtime-checked rather than statically proved. (Primitive-op and binding obligations *inside* the handle body walk at enclosing-scope precision and can prove Tier-1; a clause body's slot-dependent obligations are fresh-scope Tier-3, while a manifest violation there — `5 / 0` — stays a loud compile error.) | [#439](https://github.com/aallan/vera/issues/439) |