diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfaa669e1..ffe9d2659 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/.github/workflows/release.yml b/.github/workflows/release.yml index c0b89eab6..3b67ad4aa 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/.gitignore b/.gitignore index 1a9c5f8db..b444b33cf 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/.pre-commit-config.yaml b/.pre-commit-config.yaml index ea3e324ea..bd5a781eb 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/AGENTS.md b/AGENTS.md index 25ba5e087..f0f8b9dd5 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 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 214 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 262 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 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 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 976ab0804..68a944f14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,73 @@ 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. +- **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. +- **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 + +- **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. + +- **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). + +- **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). + +- **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`. + +- **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. + + 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` 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. + +- **`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. + + +- **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. + + + ## [0.1.11] - 2026-08-13 ### Added @@ -3312,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/CLAUDE.md b/CLAUDE.md index de912bb99..d218d88bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,11 +56,13 @@ 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 214 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_corpus_canonical.py # Verify all 262 corpus programs are in canonical form (vera fmt) +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 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 @@ -78,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 @@ -92,7 +96,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/` — 244 conformance programs validating every language feature against the spec - `scripts/` — CI and validation scripts ## Writing Vera code @@ -129,7 +133,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 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/CONTRIBUTING.md b/CONTRIBUTING.md index 75ad30e8e..7effcd766 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -89,11 +89,11 @@ 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) -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 @@ -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 @@ -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: @@ -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/ENVIRONMENT.md b/ENVIRONMENT.md index 4801852e1..cff420484 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 @@ -68,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 @@ -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`, `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 +``` + +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 3d6256c29..654e63156 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 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, 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: @@ -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 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? @@ -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 (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 -- 10,486 tests, including a 214-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/HISTORY.md b/HISTORY.md index 5a7d03007..4a0f6ba00 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/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index e8c905102..a5fe35773 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -8,18 +8,21 @@ Defects in shipped compiler, runtime, or tooling behaviour — this table matche | Bug | Issue | |-----|-------| -| 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) | -| `_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) | +| 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) | +| 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) | +| 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) | | `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 @@ -32,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 — 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) | diff --git a/README.md b/README.md index 990b438bc..bd24b82f0 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. 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). @@ -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.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/RELEASING.md b/RELEASING.md index ccf3a9e65..66e874161 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -93,7 +93,16 @@ 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 — 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. +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 d3fd45c8e..d838fe6ae 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,7 +12,32 @@ 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. +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 + +*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: 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. | +| [#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 @@ -137,7 +162,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** — [#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 fedd61b33..cb82d81e3 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 @@ -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 @@ -1951,13 +1958,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, 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)). 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. @@ -2283,7 +2292,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 @@ -2452,7 +2461,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 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. @@ -2493,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 (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 b217061f5..c4e548cbe 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** | 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** | 214 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) | @@ -35,11 +35,15 @@ 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). +# 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 # Validation scripts -python scripts/check_conformance.py # conformance suite (214 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 @@ -68,32 +72,40 @@ 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` | 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` | 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,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 | -| `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 | 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 | | `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` | 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 | 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 | +| `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 | 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) | | `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,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) | -| `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` | 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) | @@ -111,11 +123,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` | 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 | -| `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,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) | @@ -125,7 +137,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) | @@ -140,6 +152,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` | 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) | | `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) | @@ -153,7 +167,8 @@ 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), 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 | 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 | @@ -170,14 +185,14 @@ 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 | | `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` | 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) | @@ -192,10 +207,10 @@ 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` | 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` | 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_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 | 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 | @@ -206,21 +221,23 @@ 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_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` | 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 | | `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 | | `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` | 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` | 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` | 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 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 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.
 
@@ -245,15 +262,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 | 39 |
-| `verify` | Type-checks and all contracts verified by Z3 | 15 |
-| `run` | Compiles to WASM and executes correctly | 160 |
+| `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. 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. 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 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 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.
 
@@ -267,6 +284,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 |
@@ -282,6 +304,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 |
@@ -307,6 +331,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 |
@@ -319,8 +345,26 @@ 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: 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` |
+| `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 |
+| `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 |
@@ -368,7 +412,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
+├── ...                        # 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
@@ -402,7 +446,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,220 tests: five stages × 244 entries)
 pytest tests/test_conformance.py -v
 
 # Via standalone script (used in CI and pre-commit)
@@ -525,7 +569,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`.
@@ -595,10 +639,90 @@ 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. Six layers cover `examples/`, and only the last three 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.
+
+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 |
+| `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 |
+
+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:
+
+| 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.
@@ -640,7 +764,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, 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
@@ -654,7 +778,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` 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.
 
@@ -847,13 +971,13 @@ 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 |
 |--------|-------------------|
-| `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 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 262 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 |
@@ -872,8 +996,9 @@ 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.  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 |
@@ -882,6 +1007,8 @@ Twenty-eight scripts in `scripts/` validate cross-cutting concerns beyond unit t
 
 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)):
@@ -945,7 +1072,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 |
 |------|-------------|
@@ -959,9 +1086,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 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 262 `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 |
@@ -971,9 +1098,10 @@ The repository configures 35 hooks across two stages: 33 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 |
 | `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) |
@@ -1000,7 +1128,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/docs/SKILL.md b/docs/SKILL.md
index 5b8b15e16..71aca508a 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
 
@@ -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)
@@ -1907,13 +1914,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, 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)).
 
 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.
 
@@ -2228,7 +2237,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
 
@@ -2393,7 +2402,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 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.
 
@@ -2434,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 (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 debb05792..603293da3 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

@@ -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, 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

@@ -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 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 1666cc5a9..cb5568308 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? @@ -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, 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 @@ -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 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 4d3db3a83..6c5a566c2 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. ======================================================================== @@ -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 @@ -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) @@ -1913,13 +1920,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, 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)). 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. @@ -2234,7 +2243,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 @@ -2399,7 +2408,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 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. @@ -2440,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 (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 @@ -2478,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 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 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 @@ -2657,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 214 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 262 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. @@ -2668,7 +2677,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 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 @@ -3147,9 +3156,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 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, 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: @@ -3157,7 +3166,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 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? @@ -3213,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 (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 (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*? @@ -3256,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 -- 10,486 tests, including a 214-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 @@ -3375,6 +3384,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 @@ -3477,6 +3489,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/docs/llms.txt b/docs/llms.txt index 14c6ab45b..85cf13097 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 @@ -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): 244 programs validating every language feature against the spec. diff --git a/examples/json.vera b/examples/json.vera index bc795e481..5c6044a31 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/pyproject.toml b/pyproject.toml index 05b31f3c0..abc050b50 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/scripts/build_site.py b/scripts/build_site.py index 3f21fc160..45a123ae1 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, 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/scripts/check_conformance.py b/scripts/check_conformance.py index 679c7f0ae..f4c420e10 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/scripts/check_corpus_differential.py b/scripts/check_corpus_differential.py new file mode 100644 index 000000000..d5278ac41 --- /dev/null +++ b/scripts/check_corpus_differential.py @@ -0,0 +1,797 @@ +#!/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 +from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path, PurePath +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") + +# 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. +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: PurePath) -> 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. + + 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] + + for line in nonempty: + if _ERROR_MARKER.match(line): + reason = line + break + else: + reason = nonempty[-1] if nonempty else "compile failed with no output" + + for rendering in (str(path), path.as_posix()): + reason = reason.replace(rendering, path.name) + return reason[: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", + # 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, + 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, + ) + 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, ( + 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 " + 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: + 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(_DEFAULT_WORK_DIR), + help="where the base revision is checked out; the checkout is " + "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=_positive_seconds, 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 8d45b8c42..bf1388dad 100644 --- a/scripts/check_doc_counts.py +++ b/scripts/check_doc_counts.py @@ -5,22 +5,29 @@ 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 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]: @@ -79,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 @@ -106,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()] @@ -115,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 [] @@ -825,7 +845,382 @@ 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 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"), + ("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 + 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: + if marker in reason: + counts[name] += int(raw) + break + else: + return None + skipped = totals.get("skipped", 0) + if sum(counts.values()) != skipped: + return None + return DualTargetSplit(totals.get("passed", 0), 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") + 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) + + +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 + + +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. + + 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 = ( + 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}") + 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 += [ + item["number"] for item in payload if "pull_request" not in item + ] + 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( + "--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 +1550,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"))), + ) ) # ------------------------------------------------------------------ @@ -1446,9 +1819,42 @@ def check_readme(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)) + # ------------------------------------------------------------------ + # 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: + 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 new file mode 100644 index 000000000..88534e27f --- /dev/null +++ b/scripts/check_examples_run.py @@ -0,0 +1,934 @@ +#!/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. + +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 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 + +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`, 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. + + 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 + annotations through ``sys.modules[cls.__module__]``, which that + recipe leaves unregistered. + """ + + 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. +# 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(), + # 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",)), + # 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"), + "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. 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(), + "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 committed on-disk database `sqlitedb.vera` reads. +DB_FIXTURE = "sqlitedb.sqlite" + + +# --------------------------------------------------------------------------- +# 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 + + +# --------------------------------------------------------------------------- +# 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]] = {} + inspected: set[str] = set() + 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 + inspected.add(name) + 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." + ] + + # 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 " + 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 +# --------------------------------------------------------------------------- + + +def build_command(python: str, vera_file: Path, spec: RunSpec) -> list[str]: + """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. + """ + # 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 " + 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 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 / 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. + 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 + + 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: + result = subprocess.run( + build_command(sys.executable, vera_file, spec), + capture_output=True, + text=True, + encoding="utf-8", + cwd=str(workdir), + # 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, + 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]}" + ) + 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 + + +# --------------------------------------------------------------------------- +# 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() + + # 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 is_heading[i] and _TABLE_HEADING in line: + start = i + break + if start is None: + return None + + rows: dict[str, str] = {} + for i in range(start + 1, len(lines)): + if is_heading[i]: # the next heading ends the subsection + break + m = _ROW_RE.match(lines[i]) + 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 + + +# --------------------------------------------------------------------------- +# 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], + sentinel_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( + "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 " + "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 +# --------------------------------------------------------------------------- + + +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: + 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)) + + 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]}") + + # 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: + print(line, file=sys.stderr) + return 1 + + print("\nAll runnable examples execute trap-free.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check_grammar_alignment.py b/scripts/check_grammar_alignment.py index 8688ca59c..4e04bdbd4 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,65 @@ 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). + """ + 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``. + + 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. """ - return line.split("//")[0] + index = start + 1 + in_class = False + class_start = -1 + while index < len(line): + char = line[index] + if char == "\\": + index += 2 + continue + 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 # Names that appear on one side only, on purpose. Not drift; do not "fix". @@ -254,19 +321,411 @@ 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: + # 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("//"): + 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. + + 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", code)} & rules, + set(_TERMINAL_REF.findall(code)), + ) + + +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 +765,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 f7f3e3d7c..1a7e68842 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,130 @@ 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, + budget: int = RELEASE_BODY_BUDGET, + 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 "") + # 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 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})**" + ) + + 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, budget=budget, 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 +504,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 +538,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/01-lexical-structure.md b/spec/01-lexical-structure.md index cdc8a6c9c..4a57b425c 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/02-types.md b/spec/02-types.md index 6e2889424..ad3ac3cdc 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/05-functions.md b/spec/05-functions.md index ac45f89e3..08237006b 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/spec/06-contracts.md b/spec/06-contracts.md index 0ca79ae4c..3b7ff81b7 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). 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/07-effects.md b/spec/07-effects.md index 6f560700e..ffcc35380 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/spec/08-modules.md b/spec/08-modules.md index 0a5b5001f..5b066aaac 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). 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 @@ -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 @@ -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 @@ -321,9 +392,11 @@ 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` data types (with their constructors). +- 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 @@ -525,5 +598,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/09-standard-library.md b/spec/09-standard-library.md index c2617fb14..598f008a2 100644 --- a/spec/09-standard-library.md +++ b/spec/09-standard-library.md @@ -2075,8 +2075,26 @@ 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_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. +- **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:** @@ -2144,7 +2162,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, 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:** @@ -2256,7 +2293,56 @@ 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. 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: + +- **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 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 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 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 +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:** diff --git a/spec/10-grammar.md b/spec/10-grammar.md index 4fdde43cb..20de2afe0 100644 --- a/spec/10-grammar.md +++ b/spec/10-grammar.md @@ -21,7 +21,13 @@ 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. +// 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 @@ -94,6 +100,7 @@ SEMICOLON: ";" DOUBLE_COLON: "::" BAR: "|" UNDERSCORE: "_" +HOLE: "?" // Literals INT_LIT: /0|[1-9][0-9]*/ @@ -182,7 +189,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 +287,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 +310,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/spec/11-compilation.md b/spec/11-compilation.md index e11905eee..3b9eb4327 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. @@ -615,8 +615,12 @@ 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, 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. + +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). +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/spec/12-runtime.md b/spec/12-runtime.md index 86097c1f7..42ca59667 100644 --- a/spec/12-runtime.md +++ b/spec/12-runtime.md @@ -658,9 +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, 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, `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. -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`. `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. + +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 @@ -685,8 +689,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 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 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. 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/codegen_helpers.py b/tests/codegen_helpers.py index 09f0e953e..847bd850e 100644 --- a/tests/codegen_helpers.py +++ b/tests/codegen_helpers.py @@ -38,6 +38,77 @@ _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 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 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/conformance/ch02_generic_arg_branch_join.vera b/tests/conformance/ch02_generic_arg_branch_join.vera new file mode 100644 index 000000000..6aa452c92 --- /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/ch04_match_pair_scrutinee.vera b/tests/conformance/ch04_match_pair_scrutinee.vera new file mode 100644 index 000000000..2d140917a --- /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/ch05_reserved_contextual_keyword_fn_rejected.vera b/tests/conformance/ch05_reserved_contextual_keyword_fn_rejected.vera new file mode 100644 index 000000000..ceef23133 --- /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/ch07_exn_payload_guard.vera b/tests/conformance/ch07_exn_payload_guard.vera new file mode 100644 index 000000000..0ebbdb9d5 --- /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/ch07_invisible_import_op_name.vera b/tests/conformance/ch07_invisible_import_op_name.vera new file mode 100644 index 000000000..6681c9a2a --- /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 000000000..6057756bc --- /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/ch07_op_name_user_shadow.vera b/tests/conformance/ch07_op_name_user_shadow.vera new file mode 100644 index 000000000..90666b7d9 --- /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 000000000..a98ebe085 --- /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/ch08_alias_shadows_prelude_adt_name.vera b/tests/conformance/ch08_alias_shadows_prelude_adt_name.vera new file mode 100644 index 000000000..74586c82b --- /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/ch08_ambiguous_import_adt_lib_bool.vera b/tests/conformance/ch08_ambiguous_import_adt_lib_bool.vera new file mode 100644 index 000000000..b6ced22a0 --- /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 000000000..709d75026 --- /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 000000000..b182730f2 --- /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 000000000..783da5648 --- /dev/null +++ b/tests/conformance/ch08_ambiguous_import_adt_swapped_rejected.vera @@ -0,0 +1,21 @@ +-- 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. 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; + +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 000000000..c4fe73fea --- /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 000000000..7eb1ca1a4 --- /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 000000000..abc79957d --- /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 000000000..22eb7df19 --- /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 000000000..abf2dd35a --- /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/ch08_module_generic_diamond.vera b/tests/conformance/ch08_module_generic_diamond.vera new file mode 100644 index 000000000..ae780c5d0 --- /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 000000000..5b0fcbd23 --- /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 000000000..71eac3acd --- /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 000000000..e57e504e8 --- /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/ch08_module_prelude_adt_contention_rejected.vera b/tests/conformance/ch08_module_prelude_adt_contention_rejected.vera new file mode 100644 index 000000000..1b7db9ca5 --- /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 000000000..d3614e349 --- /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 000000000..c002246b2 --- /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/ch09_invisible_import_ability_op.vera b/tests/conformance/ch09_invisible_import_ability_op.vera new file mode 100644 index 000000000..8630d23e1 --- /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 000000000..610cf5fbc --- /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_json_accept_domain.vera b/tests/conformance/ch09_json_accept_domain.vera new file mode 100644 index 000000000..bd2cd4443 --- /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/ch09_nested_helper_family_op_name.vera b/tests/conformance/ch09_nested_helper_family_op_name.vera new file mode 100644 index 000000000..c2b64560f --- /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 000000000..aef9b8f46 --- /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 9a1b08928..dad353fa6 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", @@ -1324,6 +1338,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", @@ -1844,7 +1889,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", @@ -1863,6 +1908,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", @@ -2802,6 +2865,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", @@ -2830,6 +2949,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", @@ -3025,6 +3161,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", @@ -3080,5 +3244,266 @@ "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" + ] + }, + { + "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" + ] + }, + { + "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": "E156", + "features": [ + "ambiguous_import", + "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" + ] + }, + { + "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/conformance/vera/shadowlib.vera b/tests/conformance/vera/shadowlib.vera new file mode 100644 index 000000000..3e3bc8c36 --- /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/json_domain_helpers.py b/tests/json_domain_helpers.py new file mode 100644 index 000000000..c0abb4713 --- /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/module_fixture_helpers.py b/tests/module_fixture_helpers.py index 15f965958..9a2a46a94 100644 --- a/tests/module_fixture_helpers.py +++ b/tests/module_fixture_helpers.py @@ -38,15 +38,41 @@ 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. + +: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 import tempfile from pathlib import Path +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 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 +131,175 @@ 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[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 + 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. + + 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 + 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(): + (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) + # 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 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.error_code, d.description) + 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, + expr_target_types=arts.expr_target_types, + module_artifacts=arts.module_artifacts, + ) + cg_errors = [ + (d.error_code, d.description) + for d in result.diagnostics if d.severity == "error" + ] + return 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_ambiguous_import_refusal_1304.py b/tests/test_ambiguous_import_refusal_1304.py new file mode 100644 index 000000000..ae2fd0be2 --- /dev/null +++ b/tests/test_ambiguous_import_refusal_1304.py @@ -0,0 +1,1202 @@ +"""#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, + build_multi_module_past_check, + 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 _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)}, + # 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, + ) + + +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*. + + 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 = _run_check_json( + [sys.executable, "-m", "vera.cli", "check", "--json", str(main_path)], + seed=seed, + ) + return _parse_check_json(result, seed=seed) + + +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}, +} + + +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). + + 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 + + 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: + """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 == "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) + 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_browser.py b/tests/test_browser.py index deb309831..f160e75b9 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -12,9 +12,13 @@ from __future__ import annotations +import contextlib +import io import json import math +import random import shutil +import struct import subprocess from pathlib import Path from typing import Any @@ -27,6 +31,21 @@ 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, + _non_finite_message, + non_finite_number_message, + non_finite_parse_message, + format_json_number, +) # --------------------------------------------------------------------------- # Paths @@ -209,6 +228,58 @@ 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) + + # ``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): + native_out = execute(result, tee_stdout=True) + 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}" + ) + 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 "") + 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. @@ -2621,6 +2692,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). @@ -3137,8 +3336,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 +3358,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 +3666,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 +3690,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 +3705,664 @@ 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 + + +# (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() { - IO.print(round_trip("[1,2]")) + 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))) } """ - 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]" + 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). + + 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(json_stringify(JNumber({expr}))) +}} +""" + + @pytest.mark.parametrize( + ("case_id", "expr", "rendered"), + [ + ("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, rendered: str, tmp_path: Path, + ) -> None: + native, browser = _both_failures( + self._SRC.format(expr=expr), tmp_path, f"jsonnf_{case_id}", + ) + # 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 + + +# 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. + +_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_PREFIX) + assert browser.startswith(ERR_PREFIX) + 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). + + 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: @@ -3706,14 +4554,14 @@ 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 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 @@ -3738,121 +4586,509 @@ 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. - 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 - # Recursive descents found the h2 and the fence inside the quote. - assert (has_h2, has_py, n_blocks) == ("true", "true", "1") + # 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. 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 + + +_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", + # 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 +# ``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"), + # 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"), + ("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. + """ + + @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() -{ - match md_parse("- first\n continued\n") { +{{ + 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. + + 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 + + @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: + """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([MdThematicBreak(), MdBlockQuote([])]))) } """ - 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" + assert _parity_stdout(src, tmp_path, "md_empty_bq") == "---\n\n>" - 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. + 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 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. + 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. + """ + src = """ +public fn main(@Unit -> @Unit) + requires(true) ensures(true) effects() +{ + IO.print(md_render(MdDocument([MdBlockQuote([ + MdCodeBlock("sh", "one\\ntwo\\nthree") + ])]))) +} +""" + assert _parity_stdout(src, tmp_path, "md_bq_fence_adt") == ( + "> ```sh\n> one\n> two\n> three\n> ```" + ) - 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. + def test_nested_documents_separator_is_quoted( + self, tmp_path: Path, + ) -> None: + """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. """ - 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([ + MdDocument([ + MdParagraph([MdText("first")]), + MdParagraph([MdText("second")]) + ]) + ])]))) } """ - 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_blank_adt") == ( + "> first\n>\n> second" + ) - # Render 2: md_render(md_parse(md_render(md_parse(src)))). - two = r""" + 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() { - 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([ + 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_children_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/tests/test_check_corpus_differential.py b/tests/test_check_corpus_differential.py new file mode 100644 index 000000000..14ab9552b --- /dev/null +++ b/tests/test_check_corpus_differential.py @@ -0,0 +1,895 @@ +"""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 argparse +import importlib.util +import io +import json +import os +import subprocess +import threading +from pathlib import Path, PureWindowsPath +from typing import Any + +import pytest + +_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 + + 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 +# --------------------------------------------------------------------------- + + +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 + # 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 + 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: + root = Path("/scratch/base") + message = _MOD.canary_error( + "/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 + # 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; + 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_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.""" + 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 + + +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. + + 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() + + def fake_compile(path: Path) -> Any: + threads.add(threading.get_ident()) + return _ok(path.name) + + _MOD.collect(files, root, fake_compile, jobs=4) + 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") + 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 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): + 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" + + +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_doc_counts.py b/tests/test_check_doc_counts.py index 8e5f44ba4..744883b24 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. @@ -26,6 +26,7 @@ from __future__ import annotations import importlib.util +import re from pathlib import Path from typing import Any @@ -207,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" ) @@ -663,3 +665,387 @@ 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 + # 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.""" + 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 + # 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.""" + 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 + + 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. +# +# 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 + # 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: + 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 + # 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 + + +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_check_examples_run.py b/tests/test_check_examples_run.py new file mode 100644 index 000000000..c9e08d899 --- /dev/null +++ b/tests/test_check_examples_run.py @@ -0,0 +1,1315 @@ +"""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 +import re +import subprocess +import sys +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 + + +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] +} +""" + + +# 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" + + +# 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) + 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. + + 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()}, + {}, + ) + 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 + 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] + 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 + 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(): + 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_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. + + 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, + ) -> None: + """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(): + 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()) + + +# --------------------------------------------------------------------------- +# 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_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: + """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] + # 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: + """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 + `test_no_spec_relies_on_the_first_export_fallback`.""" + cmd = _MOD.build_command("py", Path("/x/a.vera"), _MOD.RunSpec()) + 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( + "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 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. + + `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 + + +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_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: + """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", + ) + 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 +# --------------------------------------------------------------------------- + + +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 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) + 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(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: + """`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_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_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: + """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 report +# --------------------------------------------------------------------------- + + +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, 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 + 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. + + 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"], + sentinel_errors=[], + doc_errors=["one doc problem", "another doc problem"], + failures=[], + ) + 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+)\)", "\n".join(blocks)) + } + 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"]) + 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 +# --------------------------------------------------------------------------- + + +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_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. + """ + 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" + ) + assert _MOD.parse_testing_table(doc) == {"a": "runs"} + assert _MOD.check_testing_md(doc, {"a": _MOD.RunSpec()}, {}) == [] + + 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_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 + 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()}, {}) == [] diff --git a/tests/test_checker_effects.py b/tests/test_checker_effects.py index 401f4f44c..be4acea91 100644 --- a/tests/test_checker_effects.py +++ b/tests/test_checker_effects.py @@ -891,6 +891,138 @@ 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]}" + # 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, + ) -> 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/tests/test_checker_modules.py b/tests/test_checker_modules.py index 4aaf3f72b..ea15e839c 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,330 @@ 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, 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 + + 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" + ) + # 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. + + 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. 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", + "private data Holder {\n helper(Int)\n}\n"): + with pytest.raises(ParseError): + parse_to_ast(src) + + # ===================================================================== # Module-qualified call parse tests (#95) # ===================================================================== diff --git a/tests/test_closure_boundary_widths_1255_1256_1269.py b/tests/test_closure_boundary_widths_1255_1256_1269.py index e780c895a..4ee6fef5a 100644 --- a/tests/test_closure_boundary_widths_1255_1256_1269.py +++ b/tests/test_closure_boundary_widths_1255_1256_1269.py @@ -757,11 +757,35 @@ 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") + # 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_")) + 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_codegen_alias_adt_name_width_1309.py b/tests/test_codegen_alias_adt_name_width_1309.py new file mode 100644 index 000000000..aebde4284 --- /dev/null +++ b/tests/test_codegen_alias_adt_name_width_1309.py @@ -0,0 +1,423 @@ +"""#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 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. + + For each pair, compile the alias-named program and the identical + program under a fresh alias name, and compare the emitted ``$twice`` + 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. + """ + + @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") + 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}" + ) + + @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_collections.py b/tests/test_codegen_collections.py index 6b807fa63..bae241120 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 bef8faa60..4e48bcee5 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/tests/test_codegen_pair_scrutinee_1305.py b/tests/test_codegen_pair_scrutinee_1305.py new file mode 100644 index 000000000..5d8d16803 --- /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/tests/test_conformance.py b/tests/test_conformance.py index bbeee3875..b2b2f4383 100644 --- a/tests/test_conformance.py +++ b/tests/test_conformance.py @@ -65,26 +65,55 @@ 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) - payload = json.loads(result.stdout) + 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) + 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"{entry['file']}: {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, ( - 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_exn_throw_payload_1268.py b/tests/test_exn_throw_payload_1268.py index 86d33b6c6..c5d015c15 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,36 @@ 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 re + 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, + wat_fn_body, +) from tests.verifier_helpers import _verify, _verify_err @@ -213,17 +232,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 +288,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 + + _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_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 + 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 +479,450 @@ 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 + # 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"] + 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 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 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. + + 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/tests/test_grammar_alignment.py b/tests/test_grammar_alignment.py index 6478775b5..ded0761d7 100644 --- a/tests/test_grammar_alignment.py +++ b/tests/test_grammar_alignment.py @@ -307,3 +307,447 @@ 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_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 + + +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 `*`. + + 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`.""" + 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: + """`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()) + aliases = {alias for rule, alias in _MOD.extract_lark_aliases(_lark_text()) + if rule == "fn_call"} + # 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" + for alias in aliases: + assert alias not in body + + +class TestCommentStripping: + @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.""" + 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() == "" + + +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: /[^/*]/ " + + +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"} + + +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/tests/test_handler_op_ownership_1284.py b/tests/test_handler_op_ownership_1284.py new file mode 100644 index 000000000..6cd54ba04 --- /dev/null +++ b/tests/test_handler_op_ownership_1284.py @@ -0,0 +1,419 @@ +"""#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, wat_calls +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 ------------------------ + +# 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 +} +""" + + +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_ARITY_2 + """ +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, 5)) + } +} +""") + assert len(errs) == 1, [d.description for d in errs] + 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) ------- + +_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 wat_calls(result.wat, "get") + assert not wat_calls(result.wat, "vera.state_get_Int") + + +# --- 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 wat_calls(result.wat, "get") + 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 wat_calls(result.wat, "get") + 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 wat_calls(result.wat, "put") + 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 wat_calls(result.wat, "vera.state_get_Int") + assert wat_calls(result.wat, "vera.state_put_Int") + 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 wat_calls(result.wat, shadowed), pre_fix + assert _run(source) == expected, pre_fix 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 000000000..483baf545 --- /dev/null +++ b/tests/test_infer_vera_type_join_1286.py @@ -0,0 +1,577 @@ +"""#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. + +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, 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 + +import os +import tempfile + +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 +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 })) + } +} +""" + +# 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 +# reordered. Either way the program means the same thing and must answer the +# same value. +_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)" + +_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_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, + _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. + + 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", + ) 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}" + + +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"), + [ + 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", + ), + 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( + 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". + + 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 + 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; 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/tests/test_json_accept_domain_1306_1308.py b/tests/test_json_accept_domain_1306_1308.py new file mode 100644 index 000000000..faba5f8c6 --- /dev/null +++ b/tests/test_json_accept_domain_1306_1308.py @@ -0,0 +1,720 @@ +"""``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 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, + first_domain_violation, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _parse_probe(raw_json: str) -> str: + """Run ``json_parse(raw_json)`` on the REFERENCE host and report the arm. + + 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(accept_domain_src(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_PREFIX) + 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_PREFIX) + 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_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_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 "\\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_lexical_fn_scope_1299.py b/tests/test_lexical_fn_scope_1299.py new file mode 100644 index 000000000..9d24259f2 --- /dev/null +++ b/tests/test_lexical_fn_scope_1299.py @@ -0,0 +1,1554 @@ +"""#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 collections.abc import Callable +from pathlib import Path +from typing import Any, ClassVar + +import pytest + +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 +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 + +# 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 +# --------------------------------------------------------------------- + +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) * {JOIN_SCALE} + 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 * JOIN_SCALE + 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,assignment] + 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,assignment] + 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,assignment] + 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: ast.FnDecl, **kw: Any + ) -> str | None: + 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,assignment] + 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}" + 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") + 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(()) * {JOIN_SCALE} + 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 * JOIN_SCALE + 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: 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,assignment] + 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}" + 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" + ) + + 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", + ) + } + + # `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 = "?" + 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_markdown.py b/tests/test_markdown.py index 6b70189d7..28263e988 100644 --- a/tests/test_markdown.py +++ b/tests/test_markdown.py @@ -11,6 +11,7 @@ MdBlockQuote, MdCode, MdCodeBlock, + MdDocument, MdEmph, MdHeading, MdImage, @@ -312,6 +313,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 +328,214 @@ 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```", "") + + @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 ``>``. + + 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/tests/test_module_generic_collision_1281.py b/tests/test_module_generic_collision_1281.py new file mode 100644 index 000000000..b11e30071 --- /dev/null +++ b/tests/test_module_generic_collision_1281.py @@ -0,0 +1,809 @@ +"""#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`` 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 +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.codegen_helpers import wat_fn_names +from tests.module_fixture_helpers import ( + build_multi_module, + build_multi_module_past_check, + 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) }} +""" + +# 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 == {DIAMOND_TOTAL}) + effects(pure) +{{ door1(true) * {DIAMOND_SCALE} + 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[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( + 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 + + +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.""" + + 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: + # 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.error_code, 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)) + 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, ( + f"a generic was emitted in the ENTRY's bare clone namespace, " + f"where neither owner belongs; emitted: {emitted}" + ) + # 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: + """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}") + ) + # 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 == {_TOTAL}) + effects(pure) +{{ doora(true) * {DIAMOND_SCALE} + 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, + }) == self._TOTAL + + +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. + + 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 + 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, + ) + _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) + + 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``. + + 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); + +public fn main(@Unit -> @Int) + requires(true) + ensures(true) + effects(pure) +{ gen(true) } +""" + 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}" + ) + + 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 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. + + 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; + +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) } +""" + 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}" + ) + + 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.""" + # `{{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}}; + +{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_mono_effect_op_naming_1207.py b/tests/test_mono_effect_op_naming_1207.py index 28ee55873..c37abfccf 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_monomorphize_differential.py b/tests/test_monomorphize_differential.py index 0485fdc17..d5aaef93b 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/tests/test_nat_narrowing_return_differential.py b/tests/test_nat_narrowing_return_differential.py index 6b74202b9..b5651a70e 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, wat_fn_names from vera.codegen.api import WasmTrapError from collections.abc import Iterator @@ -2737,14 +2738,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 +2761,16 @@ 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 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. 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 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 000000000..b14971ebf --- /dev/null +++ b/tests/test_new_state_family_1285.py @@ -0,0 +1,323 @@ +"""#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, wat_calls +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 wat_calls(result.wat, "vera.state_get_Bool") + + +# --- 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 wat_calls(result.wat, "vera.state_get_Nat") + + +# --- 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 wat_calls(result.wat, "vera.state_get_Int") + + +_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) 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/tests/test_prelude_adt_namespace_1277.py b/tests/test_prelude_adt_namespace_1277.py new file mode 100644 index 000000000..062f1f2ec --- /dev/null +++ b/tests/test_prelude_adt_namespace_1277.py @@ -0,0 +1,1034 @@ +"""#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 ast, 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))}" + ) + + +#: §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, +) -> 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_gen( + tmp_path: Path, decl_a: str, decl_b: str, *, + a_first: bool, uses_ordering: bool = True, +) -> 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} + +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) +}} +""" + 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}), + 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. + + 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] + 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) == [] + + +@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 == [] + + +# 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. + + 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 isinstance(tld.decl, ast.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 000000000..e061a48c9 --- /dev/null +++ b/tests/test_prelude_decl_stamp_1287.py @@ -0,0 +1,240 @@ +"""#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. + + 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" + 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 + 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 + + +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 + + # 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, +) -> 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/tests/test_release.py b/tests/test_release.py index 0981dc416..efd9781db 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,180 @@ 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.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. + + 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 +681,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 +693,59 @@ 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") + # 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) output = tmp_path / "release" / "SHA256SUMS" diff --git a/tests/test_runtime_traps.py b/tests/test_runtime_traps.py index 804944397..bc9e02283 100644 --- a/tests/test_runtime_traps.py +++ b/tests/test_runtime_traps.py @@ -36,7 +36,12 @@ import pytest from vera.cli import cmd_run -from vera.runtime.traps import WasmTrapError, _classify_trap +from vera.codegen.api import CompileResult +from vera.runtime.traps import ( + WasmTrapError, + _classify_host_error, + _classify_trap, +) if TYPE_CHECKING: from _pytest.capture import CaptureFixture @@ -1578,6 +1583,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 +2881,375 @@ 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) -> CompileResult: + 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) + 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) + assert excinfo.value.kind == "host_error" + + @pytest.mark.parametrize( + ("value", "enabled"), + [("1", True), ("true", True), ("TRUE", True), ("yes", True), + ("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, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """One truthiness rule across the ``VERA_*`` diagnostic knobs. + + 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 + + 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) + + 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 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).""" + + 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/tests/test_walker_defensive_branches_597.py b/tests/test_walker_defensive_branches_597.py index d1348cbc0..6489b3239 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/uv.lock b/uv.lock index ed492326f..917ce7c8d 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/README.md b/vera/README.md index 9456b7970..d542dbfa2 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,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()` | -| `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 | | -| ` 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 | | -| ` expressions.py` | 1,405 | | Expression synthesis (bidirectional), operators, statements | | +| `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` | 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` | 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()` | -| ` 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` | 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()` | -| `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 | | -| ` ├ 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` | 265 | | 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` | 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` | +| `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` | | ` 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,25 +124,25 @@ 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` | 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) | | +| ` 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` | -| ` traps.py` | 493 | | `WasmTrapError`, `_classify_trap`, source-backtrace resolution | | +| `runtime/` | 4,817 | Execute | wasmtime host layer (#421): traps + per-effect host-binding families | `register_*()`, `WasmTrapError` | +| ` 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) | | @@ -150,19 +150,20 @@ 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` | -| `skip.py` | 241 | All | Codegen-internal control-flow exceptions behind structured skip diagnostics (#626) | `CodegenSkip`, `CodegenInvariantError` | +| `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` | 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()` | -| `_since.py` | 375 | All | Best-effort `since` version attribution for built-ins, effects, abilities | | +| `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,303 | | 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,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. +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,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,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. @@ -599,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. 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 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). @@ -723,7 +724,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 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. @@ -748,11 +749,11 @@ 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 (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 -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 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/__init__.py b/vera/__init__.py index 524e17db1..b5a3c0d54 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__ diff --git a/vera/_since.py b/vera/_since.py index 681b08e3e..e31ab2593 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", @@ -366,6 +369,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/browser/runtime.mjs b/vera/browser/runtime.mjs index 5db2da282..37b60405c 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(); // --------------------------------------------------------------------------- @@ -509,12 +519,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 +613,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 +679,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 +761,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])) { @@ -714,7 +771,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 +800,30 @@ function parseMarkdown(text) { function renderInline(node) { switch (node.tag) { case 'MdText': return node.text; - case 'MdCode': return '`' + 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); + // #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('') + '*'; case 'MdStrong': return '**' + node.children.map(renderInline).join('') + '**'; case 'MdLink': return '[' + node.children.map(renderInline).join('') + '](' + node.url + ')'; @@ -746,44 +832,104 @@ 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': { + // 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 = []; + 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.length > 0) out.push('>'); + for (const line of childLines) { + 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)); + 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); + }); + }); + 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 = []; + 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: - 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 -- @@ -2064,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; } @@ -2071,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] || ""; @@ -2097,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 @@ -2536,14 +2693,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 @@ -2569,7 +2734,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; @@ -2588,11 +2767,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; } @@ -2600,17 +2782,409 @@ 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(""); + } + + // ── 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); // 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) { + // #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 @@ -2625,12 +3199,12 @@ function buildImportObject(module, moduleBytes) { if (needed.has("json_stringify")) { imports.vera.json_stringify = (ptr) => { - const value = readJson(ptr); - const text = JSON.stringify(value); - // 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"); + // #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))); }; } diff --git a/vera/checker/calls.py b/vera/checker/calls.py index 070dff2e7..0fa60502a 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) @@ -698,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/checker/core.py b/vera/checker/core.py index 41d9165b3..e54aaaa92 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 # ===================================================================== @@ -420,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] @@ -914,6 +944,31 @@ 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 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) + 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/checker/modules.py b/vera/checker/modules.py index ae83adbbc..2bc581700 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,202 @@ 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"), + ): + # 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]}'" + 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/checker/registration.py b/vera/checker/registration.py index b7c107c12..6c8836414 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 " diff --git a/vera/cli.py b/vera/cli.py index 969688395..12f63f26e 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, } @@ -1328,9 +1330,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/api.py b/vera/codegen/api.py index d776d5d20..cec8ee599 100644 --- a/vera/codegen/api.py +++ b/vera/codegen/api.py @@ -51,12 +51,14 @@ 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 ( 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 +1309,32 @@ 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 + # 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 + # 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 +1345,36 @@ 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 flag_enabled("VERA_DEBUG_HOST_ERRORS"): + 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/codegen/assembly.py b/vera/codegen/assembly.py index 942a80f98..3fcec7727 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/codegen/closures.py b/vera/codegen/closures.py index 1b9158271..e3cea1686 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), @@ -378,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 59439deef..1c395343c 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/core.py b/vera/codegen/core.py index f21c54d0d..3f9c64795 100644 --- a/vera/codegen/core.py +++ b/vera/codegen/core.py @@ -27,9 +27,18 @@ 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.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 @@ -366,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 @@ -590,6 +609,35 @@ 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 + # 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 # importer discovers instantiations from ITS OWN call sites and emits @@ -916,6 +964,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 +975,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 +993,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 @@ -1332,20 +1390,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 @@ -1355,7 +1433,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). @@ -1396,16 +1477,164 @@ 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 + # 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, + self._module_type_aliases.get(owner, {}), + self._module_type_alias_params.get(owner, {}), + ) + != 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.""" @@ -1488,6 +1717,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) @@ -1534,6 +1773,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 @@ -1544,12 +1788,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: @@ -1581,6 +1863,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 +2040,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 +2061,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 +2153,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 +2202,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 +2245,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) @@ -2255,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) @@ -2283,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 @@ -2292,6 +2629,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 @@ -2303,22 +2664,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 @@ -2335,6 +2684,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) @@ -2528,6 +2892,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 e4b6310f1..925bb30fd 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 @@ -238,12 +239,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, @@ -310,12 +317,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 +368,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 +398,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 +406,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 @@ -395,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)}", @@ -420,6 +445,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), @@ -427,6 +453,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), @@ -489,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/codegen/modules.py b/vera/codegen/modules.py index b09199364..605cda4fa 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 @@ -783,8 +831,151 @@ 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( + 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, + 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 + 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()) + 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 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. + """ + 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 a259c2b0f..5bb726285 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/envflags.py b/vera/envflags.py new file mode 100644 index 000000000..59b212dac --- /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/errors.py b/vera/errors.py index 56b740bfd..ad7268fca 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", @@ -804,6 +807,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/markdown.py b/vera/markdown.py index ad2f41bfc..f775d9ee6 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): @@ -518,9 +528,29 @@ 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: + 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). 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 child_lines: result.append(f"> {line}" if line else ">") return result @@ -532,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}") @@ -563,6 +604,46 @@ 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 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 + for ch in code: + run = run + 1 if ch == "`" else 0 + longest = max(longest, run) + fence = "`" * (longest + 1) + 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}" + + def _render_inlines(inlines: tuple[MdInline, ...]) -> str: """Render inline content to a string.""" parts: list[str] = [] @@ -570,11 +651,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): diff --git a/vera/monomorphize.py b/vera/monomorphize.py index 69cb7cc32..77818e50d 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` → @@ -753,6 +757,309 @@ 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_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 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, + 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: 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) + 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" + ) + + 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, 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). + # + # ``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 sorted(exported): + if imp.names is None or name in imp.names: + names.add(name) + 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), 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, + ) + + +@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: + 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]: """The module's PUBLIC top-level generic names — what a dependent can name at all, before that dependent's own import filter narrows it.""" @@ -1347,15 +1654,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] @@ -1373,6 +1683,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 @@ -1425,6 +1746,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( @@ -1505,6 +1880,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, @@ -1512,6 +1906,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, @@ -1607,14 +2002,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, @@ -2137,8 +2534,53 @@ 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.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 + # 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.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): @@ -2154,7 +2596,25 @@ 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. + # #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 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: return self._infer_fncall_vera_type( diff --git a/vera/prelude.py b/vera/prelude.py index 39bb45692..f2d40be54 100644 --- a/vera/prelude.py +++ b/vera/prelude.py @@ -16,9 +16,13 @@ from __future__ import annotations +import functools import re +from collections.abc import Mapping +from types import MappingProxyType from vera import ast +from vera.monomorphize import canonicalize_type_aliases # #851 — synthetic origin filename for prelude-injected declarations. @@ -60,6 +64,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 +782,149 @@ def _parse_source(source: str) -> ast.Program: # Public API # ===================================================================== +@functools.lru_cache(maxsize=1) +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` + 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`. + + 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 MappingProxyType({ + tld.decl.name: tld.decl + for tld in parsed.declarations + if isinstance(tld.decl, ast.DataDecl) + }) + + +@functools.lru_cache(maxsize=1) +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. + 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()) + + +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 + 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. + + *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(key(field) 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. diff --git a/vera/runtime/decimal.py b/vera/runtime/decimal.py index e67704169..1e6f1a652 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 / diff --git a/vera/runtime/json.py b/vera/runtime/json.py index 164ed9903..8d791c593 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,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 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 @@ -73,16 +134,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/runtime/server.py b/vera/runtime/server.py index 9d9d2156c..e668692fd 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 d104cdbb8..fd043ae3e 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/slots.py b/vera/slots.py index 8b311fe33..bae8482c7 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,92 @@ ] +# ------------------------------------------------------------------ +# 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); + * 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 ``_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; + * ``_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; + * :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. 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 + 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. 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 + + # ------------------------------------------------------------------ # Canonical slot-name construction (single source of truth) # ------------------------------------------------------------------ @@ -119,9 +206,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/verifier.py b/vera/verifier.py index 18ce1e60d..9e072ec6a 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 @@ -4288,21 +4389,49 @@ 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, ) + # #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( @@ -4749,18 +4878,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) @@ -4770,14 +4900,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 @@ -4943,33 +5079,59 @@ 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: - # A concretely-@Nat formal obligates directly (#552); a - # generic (TypeVar) formal — `E.wait` instantiated as + # 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") + # 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="effect-operation argument", - guarded=False, - ) - 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, - ) + 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, @@ -6320,12 +6482,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", @@ -7655,11 +7818,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." ), @@ -8995,11 +9158,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 @@ -9011,6 +9176,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 2b5c6ad13..256314fbe 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,33 @@ 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. 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: @@ -432,19 +448,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 +495,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 @@ -503,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 @@ -544,7 +579,38 @@ 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) + # #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) elif base == "Int" and self._result_is_nat(call.args[0]): @@ -672,16 +738,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 +764,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 3747f1659..32fd47bb2 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 @@ -60,12 +60,17 @@ 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 _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) @@ -1597,13 +1602,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: @@ -2199,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: @@ -2308,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 @@ -2441,7 +2573,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 +2593,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 963fadc79..33432d951 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: ( @@ -97,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, @@ -144,6 +147,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,8 +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) + # 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 {} @@ -393,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 @@ -594,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 @@ -643,6 +718,35 @@ 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 + ``_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 ``_handle_exn_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. + + 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._scoped_fns) + def alloc_param(self) -> int: """Allocate a parameter slot (already in WASM signature). @@ -1124,7 +1228,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/data.py b/vera/wasm/data.py index e806c6a27..6a6aea3b3 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 -> ... }` diff --git a/vera/wasm/helpers.py b/vera/wasm/helpers.py index a924269d1..1e81b372c 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) # ===================================================================== diff --git a/vera/wasm/inference.py b/vera/wasm/inference.py index a384bb871..e5986351a 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 @@ -526,11 +530,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 @@ -960,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) @@ -1030,13 +1039,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): @@ -1049,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 @@ -1061,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). @@ -1337,9 +1365,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)) diff --git a/vera/wasm/json_serde.py b/vera/wasm/json_serde.py index 1da255f8c..3d4f42797 100644 --- a/vera/wasm/json_serde.py +++ b/vera/wasm/json_serde.py @@ -13,6 +13,28 @@ 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 + +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 +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 @@ -52,6 +74,215 @@ _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) +# --------------------------------------------------------------------------- +# +# ``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, @@ -263,3 +494,150 @@ 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`` — 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: + """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) diff --git a/vera/wasm/operators.py b/vera/wasm/operators.py index 313b12fcc..0dd2d9cfa 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}"] # -----------------------------------------------------------------