diff --git a/CLAUDE.md b/CLAUDE.md index b44a67b..45bed34 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,9 +11,9 @@ knowledge base — see § Quick links spec. |---|---| | Phase | 1 (Etch ↔ ECS) | | Current milestone | (none — between milestones) | -| Last released tag | `v0.10.7-cross-file-import` | +| Last released tag | `v0.10.8-const-private-test` | | Active branch | `main` | -| Next planned milestone | M1.0.8 — `const` top-level + `private` + `test` graduation (the remaining `non_s3_keywords` reserve list; `import` graduated in M1.0.7). M1.0.9 (extension hook `on_attach`/`on_detach` execution, text-vs-bytecode decision) follows. | +| Next planned milestone | M1.0.9 — extension hook `on_attach`/`on_detach` execution (the text-vs-bytecode serialization decision; the interp is compile-once from the AST with no runtime text-execution surface yet). `override` stays the last reserved `non_s3_keywords` member (waits for a Tier-1 overridable module). | ## Tags @@ -45,6 +45,7 @@ knowledge base — see § Quick links spec. | `v0.10.5-scene-load` | 2026-06-27 | M1.0.5 — Runtime `.scene.bin` loader → ECS | Runtime loader `src/core/scene/loader.zig` reusing `accessor.zig` verbatim: `openVerified` (magic/version + `verifyHash` → `CorruptScene`) + `buildSchemaRemap` (Schema-Registry index → runtime `ComponentId` via `idOf`, size/alignment-validated → `SchemaMismatch`/`UnknownComponent`) + per-entity `spawnDynamicWithValues` instantiation + UUID(16 B)→handle map + two-phase `on_spawned` (`ObserverRegistry.dispatchOnSpawned` + `World.dispatchOnSpawned`, all-entities-exist-first ordering) + resource loading (POD + `string` fields interned into the Tier-0 persistent heap, owned by `LoadResult`). `loadFromBytes` (byte-level core) + `loadScene(path)` (mmap). New `error.MalformedScene` (structure invalid, distinct from `CorruptScene` = hash mismatch). Persistent heap moved `src/etch/persistent.zig` → `src/core/memory/persistent.zig` (Tier 0). Bench median ~1.05 ms / 10k entities (M4 Pro, ReleaseFast). | | `v0.10.6-prefabs-crossrefs-extensions` | 2026-06-28 | M1.0.6 — prefabs + entity→entity cross-references + extension activation | `.prefab.bin` cook (standalone + `of` variants) + `instance of` flattening at scene cook (byte-identical to hand-authored). Entity→entity cross-refs via new `FieldKind.entity_` (8 B `EntityId`, component-only, default `dead`=`0xFF`) — by **name** (like `parent:`), resolved at load (`resolveCrossRefs`, bounds-checked → `MalformedScene`). Extension activation: `extensions:` grammar clause (entity + instance) + Entity Extensions Table + dedup Prefab ID Table + hooks sub-section in the `extensions_offset` region (shape A); `extends` cook (components + `requires` + `on_attach`/`on_detach` rendered as **text**). Load `applyExtensions`: resolve by name → `addComponentDynamic` (conflict → `ExtensionComponentConflict`) → fire Tier-0 `on_attach` seam (`registerOnAttach`/`dispatchOnAttach`; loader never touches the VM). **`format_version` 1→2** (region restructured; v1 → `BadVersion`, re-cook). Hook **execution** re-scoped → M1.0.9. | | `v0.10.7-cross-file-import` | 2026-06-29 | M1.0.7 — Cross-file `import` (resolver pass-1) | `import` graduated parser-up (lexer `kw_import` out of `non_s3_keywords`; `ImportDecl` AST + arena slabs; `parseImportDecl` — the 4 forms, items accept IDENT **and** TYPE_IDENT, D-D). `root.validateProject` builds the module dependency graph from `ProjectFile.name` (module path under `src/`), topo-sorts it (deps-first `checkProject` order), and detects cycles → **`E0108 ImportCycle`** (D-B: NOT E0101; E0101 stays DuplicateSymbol). **Per-module** byte-keyed exports index (`ExportEntry {kind, visibility, arena_index, item_id}` — NOT a flat global index; two modules exporting the same name never collide) extends the M0.9 `ProjectContext`. `bindImports` resolves each file's imports: selective items enter scope under their local name; module aliases record an `imported_alias` binding (qualified `m.Type` resolution deferred — D-F); diagnostics `E0103 NotAModule` / `E0104 UnknownExport` / `E0107 ImportPrivateItem` (wired-but-dormant until `private`, D-G). `checkComponentInstance` resolves an imported component **cross-arena** (decl fetched from its defining arena, field names compared by **bytes**) → **unblocks the E1793 false positive**: a `.prefab.etch` importing its components validates clean; E1793 fires only for a genuinely-undeclared component. Cross-arena field-TYPE check (E1795) is builtin-typed-only; named foreign field types are a documented residual. | +| `v0.10.8-const-private-test` | 2026-06-29 | M1.0.8 — `const` top-level + `private` + `test` graduation | The last three `non_s3_keywords` graduate parser-up (`override` stays reserved). Lexer: `kw_const`/`kw_private`/`kw_test` added to `s3_keywords`, removed from the reserve list (identifier→keyword logic unchanged). AST: `ConstDecl`/`TestDecl` side-slabs + `Visibility {public, private}` field on the `Item` node (`itemVisibility`/`setItemVisibility`). Parser: `parseConstDecl` (`const ( IDENT \| TYPE_IDENT ) : type = const_expr`, top-level only — `parseStmt` untouched, so `const` in a block is a parse error per part1 §4.5); `parseTestDecl` (`test STRING block`, reuses `parseBlockExpr`, no execution); `private` prefix in `parseOneTopLevel` (after annotations, before dispatch; rejects `private import/const/type`; sets the item `.private`). Lockstep set extended {dispatch, `recoverToTopLevel` stop-set, the single error-message enumeration} — `private` adds no stop-set member. Resolver: `SymbolKind` += `const_`/`test_`; `pass1Collect` registers both, `checkConstValue` reuses the field-default surface (`E1101 NotConstEvaluable` + `E0200 TypeMismatch`); tests registered but not exported. `buildExports` exports `const_decl` and reads `Item.visibility` per decl → **activates the dormant `E0107 ImportPrivateItem`** check. **Cleared M1.0.7 debts**: cross-file `const` resolves; selectively importing a `private` item emits `E0107`. | ## Hypotheses validated by spikes @@ -75,6 +76,7 @@ knowledge base — see § Quick links spec. - **`format_version` 1→2 (correction to the L68 day-1 prediction)**: the cross-references section was genuinely additive (no bump at E4, count-0 back-compatible), but the Entity Extensions Table's full structure (vs M1.0.4's bare `[0]` count-placeholder) is **not** count-0 back-compatible → required `format_version` 1→2. The `FieldKind` dispatch prediction held (`entity_` added as a new variant). `.scene.bin`/`.prefab.bin` are re-cookable Phase-1 artifacts (deterministic cook) → no v1 back-compat. - **`FieldKind.entity_` realizes `Entity` (M1.0.6)**: 8 B/8-align `EntityId`, default `dead` (`@memset 0xFF`, not `{0,0}` = a live handle to entity 0), component-only (gated to `reg_kind == .component`, mirror of resource-only `string_`/`enum_`). - **M1.0.7 scope boundary (cross-file import)**: `import` graduated parser-up (the only `non_s3_keywords` member to leave; `const`/`private`/`test`/`override` stay reserved for M1.0.8). **Validated approach**: a **per-module** byte-keyed exports index (`{name bytes → {kind, arena_index, item_id}}`) extends the M0.9 byte-keyed `ProjectContext` pattern (StringIds are per-arena) — NOT a flat global index, so two modules exporting the same name never collide; the imported-component cross-arena resolution (decl fetched from its defining arena, field names compared by bytes) **unblocks the E1793 false positive** for `.prefab.etch`. **Deferred-but-pre-wired**: module-alias qualified `m.Type` resolution (D-F — the `imported_alias` binding is recorded at E5, so the descending `Path` walk is purely additive later); `E0107 ImportPrivateItem` (D-G — wired through the exports `visibility` flag, dormant until `private` graduates M1.0.8). **Not debt — moot**: the cross-arena field-TYPE check (E1795) resolves builtin types; this is COMPLETE for components because `validateFieldsInDecl(.component_like)` admits only builtin-POD field types (named struct/enum/string rejected on components) — the named-type branch is unreachable for a valid component (forward-compat headroom only). The cross-file `const`-import acceptance test is deferred to M1.0.8 (`const` is not parseable until it graduates) — cross-file resolution is covered by the imported-component type test + the prefab unblock. +- **M1.0.8 scope boundary (`const`/`private`/`test` graduation)**: the last three `non_s3_keywords` graduate parser-up; `override` stays reserved (waits for a Tier-1 overridable module). **Top-level `const` only** — `parseStmt` is deliberately NOT extended, so a block-level `const` is a parse error; the tri-document drift (`const_stmt` under `etch-grammar.md §4.1` statements vs §4.5 "top-level only" vs `local_const` in `etch-resolver-types.md §2.1`) is a PREEXISTING cross-doc inconsistency deferred to a KB-audit conversation (NOT resolved here). **`private` is direct export-visibility + `E0107` only** — visibility inheritance (`etch-resolver-types.md §10.2`) and `W0902 PrivateTypeInPublicImpl` stay additive/deferred; `private` is parsed as a prefix on a `declaration_body` (rejects `private import/const/type`) and adds no `recoverToTopLevel` stop-set member. **`test` is parse + validate + symbol registration only** — no execution surface exists (same blocker family as M1.0.9); tests register a `test_` symbol but are never exported. **Residual**: a string-named `test "X"` registers under the byte sequence `X` via `registerSymbol`, so it shares the name namespace with identifier-named symbols (a `test "Foo"` collides with `component Foo` → `E0101`); acceptable for M1.0.8, revisit when the M1.0.9 test-runner formalizes test identity. **Cross-file tests** live in `tests/etch/import_resolve_test.zig` (the `validateProject` harness), not inline in `types.zig` (which cannot reach `validateProject` — a tier-up dependency). ## Non-negotiable rules diff --git a/briefs/M1.0.8-const-private-test.md b/briefs/M1.0.8-const-private-test.md new file mode 100644 index 0000000..23a9921 --- /dev/null +++ b/briefs/M1.0.8-const-private-test.md @@ -0,0 +1,154 @@ +# M1.0.8 — Graduate `const` (top-level), `private`, and `test` + +> **Status:** CLOSED +> **Phase:** 1.0 +> **Branch:** `phase-1/etch/const-private-test` +> **Planned tag:** `v0.10.8-const-private-test` +> **Dependencies:** M1.0.7 (`v0.10.7-cross-file-import`) +> **Opened:** 2026-06-29 +> **Closed:** 2026-06-29 + +--- + +# FROZEN SECTION + +*Produced by Claude.ai. Not modifiable by Claude Code outside a Claude.ai round-trip (cf. § Recorded deviations).* + +## Context + +M1.0.8 graduates the last top-level reserved keywords still rejected at lex time — `const`, `private`, and `test` — parser-up (lexer to AST to parser to resolver), the same shape as `import` in M1.0.7. These keywords are currently a deliberate parse failure: lexed as `error_unknown_keyword`, rejected as `UnsupportedConstructInS3`. The milestone also clears two deferred debts of M1.0.7: cross-file resolution of a `const` (deferred acceptance criterion), and activation of the dormant `E0107 ImportPrivateItem` check (wired in M1.0.7 but unreachable until a declaration can be marked `private`). `override` stays reserved (it waits for a Tier-1 overridable module, cf. M0.8 tag annotation and `engine-phase-1-plan.md`). + +## Scope + +- **Lexer graduation.** Add `kw_const`, `kw_private`, `kw_test` to `TokenKind`; add the three `KeywordEntry` rows to `s3_keywords`; remove `"const"`, `"private"`, `"test"` from `non_s3_keywords`. Keep `"override"` in `non_s3_keywords`. Update the `non_s3_keywords` doc comment to record the graduation (mirror the `import` note). The lexer identifier-to-keyword logic needs no change. +- **AST nodes.** Add a `ConstDecl` struct (`name: StringId`, `type_node: NodeId`, `value: NodeId`) and a `TestDecl` struct (`name: StringId` for the string literal, `body: NodeId` block ref), each with its arena slab; wire `AstArena.addItem` for `const_decl` and `test_decl` (the `ItemKind` enum slots `const_decl` / `test_decl` already exist). Add a `visibility` field (enum `{ public, private }`, default `.public`) to the top-level `Item` node, set by the parser. +- **Parser — `const` (top-level only).** `parseConstDecl` implementing `const_stmt = "const" , ( IDENT | TYPE_IDENT ) , ":" , type , "=" , const_expression`. The name accepts `type_ident` (canonical `SCREAMING_SNAKE_CASE` lexes as `type_ident`). Add `kw_const` to the `parseTopLevel` dispatch, to the `recoverToTopLevel` stop-set, and to BOTH top-level error-message construct enumerations (lockstep). `parseStatement` does NOT handle `kw_const` — `const` inside a block falls through to a parse error (top-level only, per `etch-reference-part1.md §4.5`). +- **Parser — `private` modifier.** Parse the optional `private` prefix in `parseOneTopLevel`, after `parseAnnotations` and before `parseTopLevel` dispatch; set the resulting top-level `Item` visibility to `.private`. `private` is grammatically allowed only on `declaration_body` (`etch-grammar.md §5.1`): if `private` is followed by `kw_import`, `kw_const`, or `kw_type`, emit a parse error (generic syntax error, no new diagnostic code). +- **Parser — `test` block.** `parseTestDecl` implementing `test_decl = "test" , STRING_LITERAL , block`, reusing the existing block/statement parser. Add `kw_test` to the `parseTopLevel` dispatch, to the `recoverToTopLevel` stop-set, and to both error-message enumerations (lockstep). `@tag` / `@skip` / `@only` annotations flow through the existing `parseAnnotations`. +- **Resolver — symbols, exports, visibility.** Add `const_` and `test_` to the resolver `SymbolKind`; register `const` / `test` top-level symbols via `registerSymbol`. In `buildExports`: export `const_decl` (always `.public` — `const` cannot be `private` per `etch-grammar.md §5.1`) and read each item visibility (`Item.visibility`) to set `ExportEntry.visibility` (`.private` for `private` declarations). This activates the dormant `E0107 ImportPrivateItem` check in `bindImports` (already implemented, gated on `entry.visibility == .private`). Test symbols are registered but NOT exported. +- **Resolver — `const` validation.** Type-check the const value against its declared `: type` annotation (`E0200 TypeMismatch` on mismatch) and check const-evaluability (`E1101 NotConstEvaluable`), reusing the existing literal / duration / numeric const surface. +- **Cleared M1.0.7 debts (explicit acceptance):** (a) a `const` declared in one module resolves cross-file when selectively imported by another; (b) selectively importing a `private` item emits `E0107`. +- **CLAUDE.md update (§3.4).** On the milestone branch, within the closing PR: update the current-state table, add one row to the Tags table (`v0.10.8-const-private-test`), update the open-decisions section if applicable, and the "Last updated" date. No narrative prose. + +## Out of scope + +- **`test` execution / test-runner harness.** No interpreter runtime-execution surface exists (the interpreter is compile-once from the AST). This is the same blocker family as M1.0.9. M1.0.8 delivers `test` parse + validate + symbol registration only — no collection, no assertion execution, no pass/fail reporting. +- **Statement-level / local `const`.** Top-level only (`etch-reference-part1.md §4.5`); `const` inside a block is a parse error. The tri-document inconsistency (`const_stmt` listed under `etch-grammar.md §4.1` statements vs. §4.5 "top-level only" vs. `local_const` in `etch-resolver-types.md §2.1`) is a PREEXISTING cross-doc drift, deferred to a dedicated KB-audit conversation (`engine-development-workflow.md §3.6.2`). Do NOT resolve it here, and do NOT add `kw_const` to `parseStatement`. +- **`override` graduation.** Stays in `non_s3_keywords`. +- **Visibility inheritance and `W0902`.** `etch-resolver-types.md §10.2` (a `private` type impl/methods inherit private) and the `W0902 PrivateTypeInPublicImpl` warning are additive — deferred. M1.0.8 scopes `private` to the direct export-visibility + `E0107` path only. +- **Const value folding/inlining** beyond the existing literal / duration / numeric surface — additive. +- **Qualified module-alias resolution (`m.Type`).** Independent of M1.0.8 (`imported_aliases` binding is recorded; the `m.Type` resolution is separate, still open). Do not attach it here. +- **`E0106 AmbiguousEnumVariant`.** Defined in `etch-diagnostics.md §4` but absent from `src/etch/diagnostics.zig` — a pre-existing enum-diagnostic gap, orthogonal to this milestone. + +## Specs to read first + +1. `etch-grammar.md` — §4.1 (`const_stmt`, corrected this milestone to `( IDENT | TYPE_IDENT )`), §5.1 (`declaration` / `visibility_modifier` / `declaration_body`), §17 (`test_decl`) +2. `etch-reference-part1.md` — §1.3 (visibility: public default, `private` explicit), §4.5 (`const` top-level only) +3. `etch-resolver-types.md` — §2.1 (`Symbol` / `SymbolKind` / `Visibility`), §10 (visibility chain, esp. §10.4 import-private to `E0107`), §11 (const evaluation) +4. `etch-diagnostics.md` — §4 (E01XX: `E0107 ImportPrivateItem`), `E1101 NotConstEvaluable` +5. `engine-phase-1-plan.md` — M1.0.8 line +6. `etch-validation-ecs.md` — only if `test` / `const` touch validation (`.d.etch` restrictions `E1900`–`E1902`) + +## Files to create or modify + +(Tests live inline in the source `.zig` files, per the repo convention — no separate `tests/` files.) + +- `src/etch/token.zig` — modify — `TokenKind` += `kw_const` / `kw_private` / `kw_test`; `s3_keywords` += 3 rows; `non_s3_keywords` -= const/private/test (keep override); comment update; inline graduation test +- `src/etch/lexer.zig` — modify — inline test asserting const/private/test now lex to their `kw_*` kinds (logic unchanged) +- `src/etch/ast.zig` — modify — `ConstDecl` + `TestDecl` structs + slabs; `addItem` wiring; `visibility` field on `Item` +- `src/etch/parser.zig` — modify — `parseConstDecl`, `parseTestDecl`, `private` prefix in `parseOneTopLevel`; `parseTopLevel` dispatch + `recoverToTopLevel` stop-set + both error strings; inline parse/recovery tests +- `src/etch/types.zig` — modify — `SymbolKind` += `const_` / `test_`; `registerSymbol` for const/test; const value type-check + `E1101`; inline resolver tests (cross-file const, `E0107`, type mismatch, test not exported) +- `src/etch/root.zig` — modify — `buildExports`: export `const_decl` + read `Item.visibility` +- `CLAUDE.md` — modify — §3.4 update (committed on the branch, in the closing PR) + +## Acceptance criteria + +### Tests + +- `src/etch/token.zig` — `test "const/private/test graduate to s3 keywords"` — the three lex to `kw_const` / `kw_private` / `kw_test`; `override` still lexes to `error_unknown_keyword` +- `src/etch/lexer.zig` — `test "lexer promotes const/private/test"` — kind assertions per keyword +- `src/etch/parser.zig` — `test "parse top-level const decl"` — `const MAX_PLAYERS: int = 16` parses; `const PI: float = 3.14` (type_ident name) parses +- `src/etch/parser.zig` — `test "const inside block is a parse error"` — `const` in a rule/fn body fails to parse +- `src/etch/parser.zig` — `test "parse private modifier on declaration_body"` — `private component`, `private fn` parse with `Item.visibility == .private` +- `src/etch/parser.zig` — `test "private before import/const/type is a parse error"` +- `src/etch/parser.zig` — `test "parse test block"` — `test "name" { ... }` parses; `@tag(.unit)` annotation accepted +- `src/etch/parser.zig` — `test "recovery after broken const/private/test preserves following valid decl"` — lockstep recovery +- `src/etch/types.zig` — `test "const exported and resolvable cross-file"` — module A `const`, module B `import a { THE_CONST }` resolves (cleared M1.0.7 debt) +- `src/etch/types.zig` — `test "import of private item emits E0107"` — `private component` in A, `import a { Secret }` in B emits `E0107` (activation) +- `src/etch/types.zig` — `test "const type mismatch emits E0200"` and `test "non-const-evaluable const emits E1101"` +- `src/etch/types.zig` — `test "test block registered as test_ symbol and not exported"` + +### Benchmarks + +- N/A (parser/resolver graduation, no perf target). + +### Observable behavior + +- A two-file fixture (`a.etch` declaring `const ROOM_CAP: int = 8` and `private component Secret { ... }`; `b.etch` with `import a { ROOM_CAP }` and a second variant `import a { Secret }`) fed to `TypeChecker.checkProject`: the `ROOM_CAP` import resolves with zero diagnostics; the `Secret` import produces `E0107 ImportPrivateItem`. Demonstrable via the inline resolver tests or a small driver. + +### CI + +- `zig build` clean, zero warnings, on the configured matrix +- `zig build test` green (debug + ReleaseSafe) +- `zig fmt --check` green +- `zig build lint` green (once the custom linter exists) +- `commit-msg` hook green on every commit of the branch + +## Conventions + +- **Branch:** `phase-1/etch/const-private-test` +- **Final tag:** `v0.10.8-const-private-test` +- **PR title:** `Phase 1 / Etch / Graduate const, private, and test` +- **Commit convention:** Conventional Commits (cf. `engine-development-workflow.md §4.3`) +- **Merge strategy:** squash-and-merge (cf. `engine-development-workflow.md §4.6`) + +## Notes + +- **Graduation pattern = M1.0.7 (`import`).** Reference templates in the tree: `parseImportDecl` (`src/etch/parser.zig`, approx. l.3375) for a top-level parse fn; `buildExports` (`src/etch/root.zig`, approx. l.235) for the export switch. Reconfirm exact line numbers at clone — they drift. +- **`E0107` is already implemented** in `bindImports` (`src/etch/types.zig`, approx. l.2968), gated on `entry.visibility == .private`. Only `buildExports` setting `.private` activates it. No new diagnostic code, no change to the `E0107` emit site. +- **`const_stmt` token class corrected this session** in `etch-grammar.md §4.1` (`IDENT` to `( IDENT | TYPE_IDENT )`): `SCREAMING_SNAKE_CASE` const names lex as `type_ident`. `parseConstDecl` must accept `type_ident` for the name. (`SCREAMING_SNAKE_CASE` is enforced by the style lint, not the grammar.) +- **`Item.visibility` storage.** Recommended: a `visibility` field on the `Item` node (touches `addItem`), set in `parseOneTopLevel`. The intra-module resolver `Symbol` does NOT need a visibility field — intra-module everything is accessible (`etch-resolver-types.md §10.1`); only `buildExports` consumes visibility. +- **Lockstep set.** `parseTopLevel` dispatch + `recoverToTopLevel` stop-set + the two top-level error-message construct enumerations must all gain `kw_const` AND `kw_test` together. `private` is a prefix consumed before dispatch — it does NOT add a stop-set member (the construct it precedes is already in the set). +- **§3.6.1 local audit at closing (required).** `grep -rn` on `/mnt/project/` for: `const_stmt`, `non_s3_keywords`, `kw_const` / `kw_private` / `kw_test`, `ImportPrivateItem` / `E0107`, `SymbolKind`. Patch orphan references in-session or record them as residual debt in Closing notes. Language check on the milestone diff + brief (no French in code/comments). +- **State of `const` / `private` / `test` verified @ `v0.10.7` during scoping; reconfirm at clone** (the surface is the source of truth, per the M1.0.6/M1.0.7 lesson). + +--- + +# LIVING SECTION + +*Maintained by Claude Code during the milestone.* + +## Specs read + +- [x] `etch-grammar.md` (§4.1, §5.1, §17) — read 2026-06-29 16:37 +- [x] `etch-reference-part1.md` (§1.3, §4.5) — read 2026-06-29 16:37 +- [x] `etch-resolver-types.md` (§2.1, §10, §11) — read 2026-06-29 16:37 +- [x] `etch-diagnostics.md` (§4, E1101) — read 2026-06-29 16:37 +- [x] `engine-phase-1-plan.md` (M1.0.8) — read 2026-06-29 16:37 +- [x] `etch-validation-ecs.md` (if applicable) — read 2026-06-29 16:37 + +## Execution log + +- 2026-06-29 17:20 — Surface reconfirmation (§3.6.1, "surface is source of truth"). Verified every symbol the brief names against live code. Confirmed: `ItemKind` already has `const_decl`/`test_decl` slots; code `SymbolKind` (types.zig:178) lacks `const_`/`test_` (must add); `E0107` wired+dormant in `bindImports` (types.zig:2968, `.import_private_item`); `isConstEvaluable` + `checkFieldDefault` are the const surface to reuse (E1101 `.not_const_evaluable`, E0200 `.type_mismatch`); `kw_const`/`kw_private`/`kw_test` belong in the `kw_let..kw_f64` keyword range (after `kw_import`) so `isKeywordToken` covers them. No `.etch` fixture or inline test uses these three words in identifier position — graduation is non-breaking. +- 2026-06-29 17:20 — Two surface-vs-brief findings (no scope change): (1) the brief's "BOTH top-level error-message construct enumerations" — the surface has exactly ONE (parser.zig:690); I update that one plus the `recoverToTopLevel` doc-comment narrative, keeping the lockstep set {dispatch, stop-set, error message}. (2) `lexer.zig:533` uses `const` as its still-reserved example and must be repointed to `override` (which stays reserved). +- 2026-06-29 — Implemented E1–E6 bottom-up: token.zig (graduation) → lexer.zig (test repoint + promotion test) → ast.zig (`ConstDecl`/`TestDecl` slabs + `Visibility` on `Item` + accessors) → parser.zig (`parseConstDecl`/`parseTestDecl` + `private` prefix + lockstep) → types.zig (`SymbolKind` += `const_`/`test_`, `pass1Collect` cases, `checkConstValue`) → root.zig (`buildExports` const + per-decl visibility). Each a granular commit. +- 2026-06-29 — Tests green. `zig build` clean (exit 0, no warnings); `zig build test` debug + ReleaseSafe both 255/255 steps, 872/889 passed (17 pre-existing skips, 0 failed); `zig fmt --check` clean; `zig build lint` exit 0. Etch inline collection verified (token+2, lexer+17, parser+99, types+116 by file prefix — all new tests present). `import_resolve_test.zig` 10/10 (incl. 4 new M1.0.8 cross-file tests). +- 2026-06-29 — §3.6.1 closure audit: `const_stmt` refs all legitimate (the reserved `StmtKind.const_stmt` is untouched, per out-of-scope); `non_s3_keywords` lists only `override`; `kw_const`/`kw_private`/`kw_test` confined to token/lexer/parser; language check clean (no French in the diff). Patched two stale "E0107 dormant" comments post-activation. + +## Recorded deviations + +- `dbbd598` — Lockstep "BOTH error-message enumerations" (brief Scope) resolved to the ONE that exists on the surface (parser.zig:690) + the `recoverToTopLevel` doc-comment narrative. The surface is the source of truth (§3.6.1); no behavioural change. +- `7cf3a8a` — The two cross-file acceptance tests (`const exported and resolvable cross-file`, `import of private item emits E0107`) live in `tests/etch/import_resolve_test.zig`, NOT inline in `src/etch/types.zig` as the brief's file list states. Reason: cross-file resolution needs `root.validateProject`, which `types.zig` cannot reach (root imports types — a tier-up dependency). The dedicated `validateProject` harness is the canonical home (its header already anticipated "cross-file type/const" tests). The single-file acceptance tests (E0200, E1101, registration) are inline in `types.zig` as specified. +- `5dc5e16` — Touched `src/etch/diagnostics.zig` (outside the brief's file list) for a one-line §3.6.1 orphan-reference patch: the `E0107` enum comment said "wired but dormant until M1.0.8" — now stale post-activation. Doc-only; sanctioned by the brief Notes "Patch orphan references in-session". + +## Blockers encountered + +- None. + +## Closing notes + +- **What worked:** The M1.0.7 `import` graduation was a faithful template — lexer table move + AST slab + parse-fn + lockstep set + pass-1 registration + `buildExports` row. The `E0107` path was genuinely wired-and-dormant in M1.0.7, so activation was a single change (`buildExports` reading `Item.visibility`). `checkConstValue` reused the field-default const surface verbatim (`isConstEvaluable` + `synthExpr` + `literalTypeFits`). +- **What deviated from the original spec:** Only the three recorded deviations above — all about test placement / the one-vs-two enumeration / a stale comment. No scope or behavioural deviation. `parseStmt` was left untouched (block-level `const` is a parse error, per scope); `override` stays reserved. +- **What to flag explicitly in review:** (1) cross-file tests placed in `import_resolve_test.zig` not `types.zig` (forced by the tier dependency — see deviations); (2) `diagnostics.zig` touched for the orphan-comment patch; (3) the lockstep had ONE error enumeration on the surface, not two. +- **Final measurements:** N/A (parser/resolver graduation, no perf gate). Suite: 872 tests pass debug + ReleaseSafe; +17 new tests (token 1, lexer 1, parser 6, types 4, import_resolve 5 — incl. one extra public/private-mix coverage test beyond the brief's list, and a positive "const checks clean" test). +- **Residual risks / tech debt left intentionally:** A string-named `test "X"` registers under the byte sequence `X` via `registerSymbol`, sharing the name namespace with identifier-named symbols (`test "Foo"` would collide with `component Foo` → `E0101`). Acceptable for M1.0.8 (the brief mandates `registerSymbol`; test names are normally descriptive strings); revisit when the M1.0.9 test-runner formalizes test identity. Out-of-scope-by-design: statement-level `const` (tri-doc drift, deferred to KB-audit), visibility inheritance + `W0902`, qualified `m.Type`, `override`. diff --git a/src/etch/ast.zig b/src/etch/ast.zig index a57f0b6..2b029c2 100644 --- a/src/etch/ast.zig +++ b/src/etch/ast.zig @@ -164,6 +164,13 @@ pub const ItemKind = enum { override_decl, }; +/// Top-level declaration visibility (M1.0.8, `etch-grammar.md` §5.1 +/// `visibility_modifier`, `etch-reference-part1.md` §1.3). Public by default; +/// `.private` is set by the parser when a `private` prefix precedes a +/// `declaration_body`. Consumed only by `buildExports` (cross-module access); +/// intra-module resolution ignores it (`etch-resolver-types.md` §10.1). +pub const Visibility = enum { public, private }; + /// Closed enum of statement kinds reachable from an Etch rule body. /// `// S3` variants are implemented; the others are reserved for /// later milestones and rejected at parse-time in S3. @@ -620,6 +627,29 @@ pub const TypeAliasDecl = struct { target: NodeId, }; +/// Side-slab entry for a top-level `const` declaration (M1.0.8, +/// `etch-grammar.md` §4.1: `const_stmt = "const" ( IDENT | TYPE_IDENT ) ":" +/// type "=" const_expression`). `name` is the interned binding name (a +/// `SCREAMING_SNAKE_CASE` const lexes as `TYPE_IDENT`; both cases accepted). +/// `type_node` is the declared `: type` annotation (mandatory — no inference +/// on a const, part1 §3.5); `value` is the const expression, validated for +/// const-evaluability + type at resolve (E1101 / E0200). Top-level only. +pub const ConstDecl = struct { + name: StringId, + type_node: NodeId, + value: NodeId, +}; + +/// Side-slab entry for a top-level `test` block (M1.0.8, `etch-grammar.md` +/// §17: `test_decl = "test" STRING_LITERAL block`). `name` is the interned +/// string-literal label; `body` is a `block_expr` NodeId (the reused +/// block/statement parser). M1.0.8 delivers parse + validate + symbol +/// registration only — there is no execution surface (that is M1.0.9). +pub const TestDecl = struct { + name: StringId, + body: NodeId, +}; + /// Side-slab entry for a `rule` declaration: params, optional `when` /// clause, body statement range, and annotation range. pub const RuleDecl = struct { @@ -2244,6 +2274,10 @@ const Item = struct { kind: ItemKind, data: u32, span: SourceSpan, + /// M1.0.8 — `.private` when a `private` prefix precedes this top-level + /// declaration_body; `.public` otherwise (the dominant case, so the + /// default keeps every existing `addItem` call literal valid). + visibility: Visibility = .public, }; const Stmt = struct { @@ -2323,6 +2357,8 @@ pub const AstArena = struct { /// for a top-level callable. `ImplDecl` references a `(start, len)` run. impl_methods: std.ArrayListUnmanaged(FnDecl) = .empty, type_alias_decls: std.ArrayListUnmanaged(TypeAliasDecl) = .empty, + const_decls: std.ArrayListUnmanaged(ConstDecl) = .empty, + test_decls: std.ArrayListUnmanaged(TestDecl) = .empty, data_decls: std.ArrayListUnmanaged(DataDecl) = .empty, data_entries: std.ArrayListUnmanaged(DataEntry) = .empty, theme_decls: std.ArrayListUnmanaged(ThemeDecl) = .empty, @@ -2543,6 +2579,8 @@ pub const AstArena = struct { self.trait_decls.deinit(gpa); self.impl_methods.deinit(gpa); self.type_alias_decls.deinit(gpa); + self.const_decls.deinit(gpa); + self.test_decls.deinit(gpa); self.data_decls.deinit(gpa); self.data_entries.deinit(gpa); self.quest_decls.deinit(gpa); @@ -2748,6 +2786,23 @@ pub const AstArena = struct { return try self.addItem(gpa, .type_alias, idx, span); } + /// `const Name : type = value` (M1.0.8, top-level only). Mirrors + /// `addTypeAlias` — append the side-slab entry, register the `const_decl` + /// item. + pub fn addConstDecl(self: *AstArena, gpa: std.mem.Allocator, decl: ConstDecl, span: SourceSpan) !NodeId { + const idx: u32 = @intCast(self.const_decls.items.len); + try self.const_decls.append(gpa, decl); + return try self.addItem(gpa, .const_decl, idx, span); + } + + /// `test "name" { ... }` (M1.0.8). Append the side-slab entry, register the + /// `test_decl` item. + pub fn addTestDecl(self: *AstArena, gpa: std.mem.Allocator, decl: TestDecl, span: SourceSpan) !NodeId { + const idx: u32 = @intCast(self.test_decls.items.len); + try self.test_decls.append(gpa, decl); + return try self.addItem(gpa, .test_decl, idx, span); + } + /// Resolve a type name through the top-level `type` alias chain to its /// ultimate underlying name (M0.8 v0.6 foundations). Returns `name` /// unchanged when it names no alias. Bounded by the alias count so a @@ -3352,6 +3407,20 @@ pub const AstArena = struct { return self.items.items(.data)[id.index]; } + /// Visibility of a top-level item (M1.0.8). `.public` unless the parser set + /// `.private` via `setItemVisibility`. Consumed by `buildExports`. + pub fn itemVisibility(self: *const AstArena, id: NodeId) Visibility { + std.debug.assert(id.category == .item); + return self.items.items(.visibility)[id.index]; + } + + /// Mark a top-level item `.private` (M1.0.8). Called by `parseOneTopLevel` + /// when a `private` prefix precedes the declaration_body it parsed. + pub fn setItemVisibility(self: *AstArena, id: NodeId, vis: Visibility) void { + std.debug.assert(id.category == .item); + self.items.items(.visibility)[id.index] = vis; + } + pub fn stmtKind(self: *const AstArena, id: NodeId) StmtKind { std.debug.assert(id.category == .stmt); return self.stmts.items(.kind)[id.index]; diff --git a/src/etch/diagnostics.zig b/src/etch/diagnostics.zig index dbe341c..579e2d6 100644 --- a/src/etch/diagnostics.zig +++ b/src/etch/diagnostics.zig @@ -35,7 +35,7 @@ pub const DiagnosticCode = enum { not_a_module, // M1.0.7 E5 — E0103 NotAModule (import path resolves to no module) unknown_export, // M1.0.7 E5 — E0104 UnknownExport (item absent from target's exports) enum_variant_not_found, // M0.8 — E0105 EnumVariantNotFound - import_private_item, // M1.0.7 E5 — E0107 ImportPrivateItem (wired but dormant until M1.0.8, D-G) + import_private_item, // M1.0.7 E5 — E0107 ImportPrivateItem (activated M1.0.8: buildExports sets .private from Item.visibility) import_cycle, // M1.0.7 E4 — E0108 ImportCycle (D-B: NOT E0101; E0101 is DuplicateSymbol) // ── Type errors (E0200-E0299) ── diff --git a/src/etch/lexer.zig b/src/etch/lexer.zig index d0d184a..b2b0672 100644 --- a/src/etch/lexer.zig +++ b/src/etch/lexer.zig @@ -527,16 +527,29 @@ test "lexer flags unknown Etch keyword from full grammar as error_unknown_keywor const gpa = std.testing.allocator; // `fn` graduated with the M0.8 E2 call mechanism, `ability` with its E4 // Level-B slice; the whole E6 render/anim/audio/cinematic family graduated, - // `scene`/`prefab` graduated with the E7 Level-C scene slice, and `import` - // graduated with M1.0.7 cross-file import. The remaining reserved top-level - // keyword is `const` (top-level constants, M1.0.8). - var lex = Lexer.init("fn ability const"); + // `scene`/`prefab` graduated with the E7 Level-C scene slice, `import` with + // M1.0.7 cross-file import, and `const`/`private`/`test` with M1.0.8. The + // single remaining reserved top-level keyword is `override` (M1.0.8 keeps it + // reserved until a Tier-1 overridable module exists). + var lex = Lexer.init("fn ability override"); defer lex.deinit(gpa); try expectKind(&lex, gpa, .kw_fn); try expectKind(&lex, gpa, .kw_ability); try expectKind(&lex, gpa, .error_unknown_keyword); } +test "lexer promotes const/private/test" { + const gpa = std.testing.allocator; + // M1.0.8: the three reserved keywords now lex to their own kinds (the + // identifier→keyword logic is unchanged — only the keyword tables moved). + var lex = Lexer.init("const private test"); + defer lex.deinit(gpa); + try expectKind(&lex, gpa, .kw_const); + try expectKind(&lex, gpa, .kw_private); + try expectKind(&lex, gpa, .kw_test); + try expectKind(&lex, gpa, .eof); +} + test "lexer recognizes the M1.0.7 import keyword (graduated from reserved)" { const gpa = std.testing.allocator; var lex = Lexer.init("import a.b"); diff --git a/src/etch/parser.zig b/src/etch/parser.zig index 96eb772..19a2358 100644 --- a/src/etch/parser.zig +++ b/src/etch/parser.zig @@ -608,6 +608,30 @@ pub const Parser = struct { fn parseOneTopLevel(self: *Parser) ParseError!void { try self.surfaceTokenErrors(); const annotations = try self.parseAnnotations(); + // M1.0.8 — optional `private` visibility prefix (grammar §5.1: + // `{ annotation } ( ... | visibility_modifier , declaration_body )`). + // `private` is consumed AFTER annotations and BEFORE the construct + // dispatch, and applies ONLY to a declaration_body — never to a bare + // `import` / `const` / `type` declaration (rejected with a generic + // syntax error, no new diagnostic code). It adds no `recoverToTopLevel` + // stop-set member: the construct it precedes is already in the set. + if (self.peek() == .kw_private) { + _ = try self.advance(); // 'private' + switch (self.peek()) { + .kw_import, .kw_const, .kw_type => return self.parseErrFmt(self.peekSpan(), "'private' applies to a declaration (component, resource, fn, struct, ...), not to '{s}'", .{self.sliceOf(self.peekSpan())}), + .eof => return self.parseErr(self.peekSpan(), "expected a declaration after 'private'"), + else => {}, + } + const before: u28 = @intCast(self.arena.items.len); + try self.parseTopLevel(annotations); + // The declaration_body appended exactly one top-level item (at + // `before`); mark it private so `buildExports` excludes it from the + // module's public surface (→ `E0107` on a cross-module import). + if (self.arena.items.len > before) { + self.arena.setItemVisibility(.{ .category = .item, .index = before }, .private); + } + return; + } try self.parseTopLevel(annotations); } @@ -620,13 +644,15 @@ pub const Parser = struct { /// S3 (`component` / `resource` / `rule`) + `type` (M0.8 alias) + `fn` / /// `async` (M0.8 E2 call mechanism) + `struct` / `impl` (M0.8 E2 block 3 /// declaration layer) + `enum` / `trait` (E2 block 3 tranches B/C) + - /// `event` / `tags` (E3 ECS layer). Later milestones extend both sites - /// together. + /// `event` / `tags` (E3 ECS layer) + `import` (M1.0.7) + `const` / `test` + /// (M1.0.8). `private` is NOT a stop-set member — it is a prefix consumed + /// before dispatch, and the declaration_body it precedes already is one. + /// Later milestones extend both sites together. fn recoverToTopLevel(self: *Parser) ParseError!void { if (self.peek() != .eof) _ = try self.advance(); while (true) { switch (self.peek()) { - .eof, .kw_import, .kw_component, .kw_resource, .kw_rule, .kw_type, .kw_fn, .kw_async, .kw_struct, .kw_impl, .kw_enum, .kw_trait, .kw_event, .kw_tags, .kw_data, .kw_routine, .kw_behavior, .kw_quest, .kw_dialogue, .kw_ability, .kw_theme, .kw_motion, .kw_input_mapping, .kw_widget, .kw_locale, .kw_effect, .kw_audio_graph, .kw_audio_score, .kw_sequence, .kw_anim_graph, .kw_shader, .kw_scene, .kw_prefab => return, + .eof, .kw_import, .kw_const, .kw_test, .kw_component, .kw_resource, .kw_rule, .kw_type, .kw_fn, .kw_async, .kw_struct, .kw_impl, .kw_enum, .kw_trait, .kw_event, .kw_tags, .kw_data, .kw_routine, .kw_behavior, .kw_quest, .kw_dialogue, .kw_ability, .kw_theme, .kw_motion, .kw_input_mapping, .kw_widget, .kw_locale, .kw_effect, .kw_audio_graph, .kw_audio_score, .kw_sequence, .kw_anim_graph, .kw_shader, .kw_scene, .kw_prefab => return, else => _ = try self.advance(), } } @@ -644,6 +670,8 @@ pub const Parser = struct { fn parseTopLevel(self: *Parser, annotations: AnnotationRange) ParseError!void { switch (self.peek()) { .kw_import => try self.parseImportDecl(annotations), + .kw_const => try self.parseConstDecl(annotations), + .kw_test => try self.parseTestDecl(annotations), .kw_component => try self.parseComponentDecl(annotations), .kw_resource => try self.parseResourceDecl(annotations), .kw_rule => try self.parseRuleDecl(annotations, false), @@ -687,7 +715,7 @@ pub const Parser = struct { } }, .eof => {}, - else => return self.parseErrFmt(self.peekSpan(), "expected top-level declaration (import | component | resource | rule | type | fn | struct | impl | enum | trait | event | tags | data | routine | behavior | quest | dialogue | ability | theme | motion | input_mapping | widget | locale | effect | audio_graph | audio_score | sequence | anim_graph | shader | scene | prefab), got '{s}'", .{self.sliceOf(self.peekSpan())}), + else => return self.parseErrFmt(self.peekSpan(), "expected top-level declaration (import | const | test | component | resource | rule | type | fn | struct | impl | enum | trait | event | tags | data | routine | behavior | quest | dialogue | ability | theme | motion | input_mapping | widget | locale | effect | audio_graph | audio_score | sequence | anim_graph | shader | scene | prefab), got '{s}'", .{self.sliceOf(self.peekSpan())}), } } @@ -708,6 +736,58 @@ pub const Parser = struct { }); } + /// Parse a top-level `const` declaration (M1.0.8, `etch-grammar.md` §4.1): + /// `const_stmt = "const" ( IDENT | TYPE_IDENT ) ":" type "=" const_expression`. + /// The name accepts BOTH `.ident` and `.type_ident` — a canonical + /// `SCREAMING_SNAKE_CASE` const lexes as `TYPE_IDENT` (the case rule is a + /// style-lint concern, not the grammar). The type annotation is mandatory + /// (no inference on a const, part1 §3.5). The value parses as an ordinary + /// expression; its const-evaluability (`E1101`) and type (`E0200`) are + /// checked at resolve. Top-level ONLY — `parseStmt` does not handle + /// `kw_const`, so a `const` inside a block falls through to a parse error + /// (part1 §4.5). The `kw_const` starter is mirrored in `recoverToTopLevel`'s + /// stop-set + the `parseTopLevel` error enumeration. Like `import` / `type`, + /// the v0.6 subset attaches no annotations to a const (range discarded). + fn parseConstDecl(self: *Parser, annotations: AnnotationRange) ParseError!void { + _ = annotations; // const carries no annotations in the v0.6 subset + const kw_span = (try self.advance()).span; // 'const' + const name_tok = if (self.peek() == .ident or self.peek() == .type_ident) + try self.advance() + else + return self.parseErr(self.peekSpan(), "expected a constant name (identifier or SCREAMING_SNAKE_CASE) after 'const'"); + const name_id = try self.internSlice(name_tok.span); + _ = try self.expect(.colon, "expected ':' and a type after the constant name (a const requires an explicit type)"); + const type_node = try self.parseType(); + _ = try self.expect(.eq, "expected '=' and a constant expression in the const declaration"); + const value = try self.parseExpr(0); + _ = try self.arena.addConstDecl(self.gpa, .{ + .name = name_id, + .type_node = type_node, + .value = value, + }, .{ .byte_start = kw_span.byte_start, .byte_end = self.arena.exprSpan(value).byte_end }); + } + + /// Parse a top-level `test` block (M1.0.8, `etch-grammar.md` §17): + /// `test_decl = "test" STRING_LITERAL block`. + /// The body reuses the ordinary block/statement parser (`parseBlockExpr`). + /// `@tag` / `@skip` / `@only` annotations flow through `parseAnnotations` + /// before dispatch (the range is discarded here — the v0.6 test subset + /// attaches no resolver semantics to them). M1.0.8 is parse + validate + + /// symbol registration only; there is no execution surface (M1.0.9). The + /// `kw_test` starter is mirrored in `recoverToTopLevel`'s stop-set + the + /// `parseTopLevel` error enumeration. + fn parseTestDecl(self: *Parser, annotations: AnnotationRange) ParseError!void { + _ = annotations; // @tag/@skip/@only parsed but carry no v0.6 resolver semantics + const kw_span = (try self.advance()).span; // 'test' + const name_tok = try self.expect(.string_literal, "expected a test name (string literal) after 'test'"); + const name_id = try self.internStringLiteral(name_tok.span); + const body = try self.parseBlockExpr(); + _ = try self.arena.addTestDecl(self.gpa, .{ + .name = name_id, + .body = body, + }, .{ .byte_start = kw_span.byte_start, .byte_end = self.arena.exprSpan(body).byte_end }); + } + // ─── Annotations ───────────────────────────────────────────────────── pub const AnnotationRange = struct { @@ -6539,6 +6619,103 @@ test "parser rejects unsupported top-level construct with E0001" { try std.testing.expectEqual(diag_mod.DiagnosticCode.parse_error, result.diagnostics[0].code); } +test "parse top-level const decl" { + const gpa = std.testing.allocator; + // M1.0.8: `const Name : type = value`. The name accepts both an ident and a + // type_ident — a canonical SCREAMING_SNAKE_CASE const lexes as type_ident. + var result = try parse(gpa, + \\const MAX_PLAYERS: int = 16 + \\const PI: float = 3.14 + ); + defer result.deinit(gpa); + try std.testing.expect(result.diagnostics.len == 0); + try std.testing.expectEqual(@as(usize, 2), result.ast.items.len); + try std.testing.expectEqual(ast_mod.ItemKind.const_decl, result.ast.items.items(.kind)[0]); + try std.testing.expectEqual(ast_mod.ItemKind.const_decl, result.ast.items.items(.kind)[1]); + // A const is always public (it cannot carry a `private` prefix). + try std.testing.expectEqual(ast_mod.Visibility.public, result.ast.items.items(.visibility)[0]); + const c0 = result.ast.const_decls.items[result.ast.items.items(.data)[0]]; + try std.testing.expectEqualStrings("MAX_PLAYERS", result.ast.strings.slice(c0.name)); +} + +test "const inside block is a parse error" { + const gpa = std.testing.allocator; + // Top-level only (part1 §4.5): `const` is not a statement, so it falls + // through to a parse error inside a rule body. + var result = try parse(gpa, + \\rule r() { + \\ const X: int = 1 + \\} + ); + defer result.deinit(gpa); + try std.testing.expect(result.diagnostics.len > 0); +} + +test "parse private modifier on declaration_body" { + const gpa = std.testing.allocator; + var result = try parse(gpa, + \\private component Secret { hash: u32 = 0 } + \\private fn helper(x: int) -> int { x } + \\component Public { value: int = 0 } + ); + defer result.deinit(gpa); + try std.testing.expect(result.diagnostics.len == 0); + try std.testing.expectEqual(@as(usize, 3), result.ast.items.len); + try std.testing.expectEqual(ast_mod.Visibility.private, result.ast.items.items(.visibility)[0]); + try std.testing.expectEqual(ast_mod.Visibility.private, result.ast.items.items(.visibility)[1]); + try std.testing.expectEqual(ast_mod.Visibility.public, result.ast.items.items(.visibility)[2]); +} + +test "private before import/const/type is a parse error" { + const gpa = std.testing.allocator; + // `private` precedes ONLY a declaration_body (grammar §5.1), never a bare + // import / const / type alias. + inline for (.{ "private import a.b", "private const X: int = 1", "private type Alias = int" }) |src| { + var r = try parse(gpa, src); + defer r.deinit(gpa); + try std.testing.expect(r.diagnostics.len > 0); + } +} + +test "parse test block" { + const gpa = std.testing.allocator; + // `test "name" { ... }`; a leading `@tag(.unit)` annotation is accepted. + var result = try parse(gpa, + \\@tag(.unit) + \\test "math works" { + \\ let x = 1 + 1 + \\} + ); + defer result.deinit(gpa); + try std.testing.expect(result.diagnostics.len == 0); + try std.testing.expectEqual(@as(usize, 1), result.ast.items.len); + try std.testing.expectEqual(ast_mod.ItemKind.test_decl, result.ast.items.items(.kind)[0]); + const td = result.ast.test_decls.items[result.ast.items.items(.data)[0]]; + try std.testing.expectEqualStrings("math works", result.ast.strings.slice(td.name)); +} + +test "recovery after broken const/private/test preserves following valid decl" { + const gpa = std.testing.allocator; + // Broken leading constructs must resync at the following `const` / `test` + // — both are recoverToTopLevel stop-set members as of M1.0.8. + var result = try parse(gpa, + \\@@@@bad + \\const ROOM_CAP: int = 8 + \\@@@@worse + \\test "ok" { } + ); + defer result.deinit(gpa); + try std.testing.expect(result.diagnostics.len > 0); + var found_const = false; + var found_test = false; + for (result.ast.items.items(.kind)) |k| { + if (k == .const_decl) found_const = true; + if (k == .test_decl) found_test = true; + } + try std.testing.expect(found_const); + try std.testing.expect(found_test); +} + test "parser recovers at top level and returns partial AST" { const gpa = std.testing.allocator; var result = try parse(gpa, diff --git a/src/etch/root.zig b/src/etch/root.zig index 8b45b0c..13cf2b4 100644 --- a/src/etch/root.zig +++ b/src/etch/root.zig @@ -247,14 +247,25 @@ fn buildExports(gpa: std.mem.Allocator, a: *const Ast, arena_index: usize, table .event_decl => .{ .name = a.event_decls.items[datas[i]].name, .kind = .event_ }, .fn_decl => .{ .name = a.fn_decls.items[datas[i]].name, .kind = .fn_ }, .type_alias => .{ .name = a.type_alias_decls.items[datas[i]].name, .kind = .type_alias }, + // M1.0.8 — a top-level `const` is exportable (always public — a + // const cannot carry a `private` prefix). `test` blocks are NOT + // listed: tests are registered intra-module but never exported. + .const_decl => .{ .name = a.const_decls.items[datas[i]].name, .kind = .const_ }, else => null, }; if (nk) |e| { // Last decl wins on a same-name dup (an intra-file dup is E0101 in // pass 1); the exports table only needs a single resolvable entry. + // M1.0.8 — read the item's visibility: a `private` declaration_body + // is recorded `.private`, which makes the dormant `E0107` check in + // `bindImports` reachable when another module imports it. + const vis: TypeChecker.Visibility = switch (a.itemVisibility(item_id)) { + .public => .public, + .private => .private, + }; try table.put(gpa, a.strings.slice(e.name), .{ .kind = e.kind, - .visibility = .public, + .visibility = vis, .arena_index = arena_index, .item_id = item_id, }); diff --git a/src/etch/token.zig b/src/etch/token.zig index 404b27c..f458d7d 100644 --- a/src/etch/token.zig +++ b/src/etch/token.zig @@ -107,6 +107,9 @@ pub const TokenKind = enum { kw_scene, // scene declaration (M0.8 E7 Level C — STRING-named scene graph, entity/instance decls) kw_prefab, // prefab declaration (M0.8 E7 Level C — STRING-named, of/extends relation, requires + on_attach/on_detach hooks) kw_import, // import directive (M1.0.7 cross-file import — module path + optional alias / selective items; graduated from non_s3_keywords) + kw_const, // top-level `const` declaration (M1.0.8 — graduated from non_s3_keywords; top-level only per part1 §4.5) + kw_private, // `private` visibility modifier prefix on a declaration_body (M1.0.8 — graduated from non_s3_keywords; grammar §5.1) + kw_test, // top-level `test "name" { ... }` block (M1.0.8 — graduated from non_s3_keywords; parse + validate only, no execution) // ── Primitive type keywords (lexed as kw_type_*) ── kw_int, @@ -271,6 +274,9 @@ pub const s3_keywords = [_]KeywordEntry{ .{ .lexeme = "scene", .kind = .kw_scene }, .{ .lexeme = "prefab", .kind = .kw_prefab }, .{ .lexeme = "import", .kind = .kw_import }, + .{ .lexeme = "const", .kind = .kw_const }, + .{ .lexeme = "private", .kind = .kw_private }, + .{ .lexeme = "test", .kind = .kw_test }, .{ .lexeme = "true", .kind = .bool_literal }, .{ .lexeme = "false", .kind = .bool_literal }, .{ .lexeme = "int", .kind = .kw_int }, @@ -301,10 +307,10 @@ pub const non_s3_keywords = [_][]const u8{ // `event` + `tags` with E3 ECS layer; `data` with E4 Level B gameplay; // `scene` + `prefab` graduated with E7 Level C — the last two construct // keywords of the v0.6 grammar; `import` graduated with M1.0.7 cross-file - // import — it now lexes as `kw_import` via `s3_keywords`) ── - "const", - "private", - "test", + // import; `const` / `private` / `test` graduated with M1.0.8 — they now + // lex as `kw_const` / `kw_private` / `kw_test` via `s3_keywords`. + // `override` is the last reserved member: it waits for a Tier-1 + // overridable module (cf. `engine-phase-1-plan.md`) ── "override", // ── Async machinery: `async` graduated with M0.8 E2 (`async fn` parsed; @@ -340,3 +346,38 @@ test "non_s3_keywords does not collide with s3_keywords" { } } } + +test "const/private/test graduate to s3 keywords" { + // M1.0.8: `const` / `private` / `test` move from the reserve list into + // `s3_keywords`, each mapped to its own `kw_*` kind. `override` is the last + // member left reserved, so it still lexes as `error_unknown_keyword`. + const T = struct { + fn s3Kind(lexeme: []const u8) ?TokenKind { + for (s3_keywords) |kw| { + if (std.mem.eql(u8, kw.lexeme, lexeme)) return kw.kind; + } + return null; + } + fn reserved(lexeme: []const u8) bool { + for (non_s3_keywords) |kw| { + if (std.mem.eql(u8, kw, lexeme)) return true; + } + return false; + } + }; + try std.testing.expectEqual(TokenKind.kw_const, T.s3Kind("const").?); + try std.testing.expectEqual(TokenKind.kw_private, T.s3Kind("private").?); + try std.testing.expectEqual(TokenKind.kw_test, T.s3Kind("test").?); + // The three are no longer in the reserve list. + try std.testing.expect(!T.reserved("const")); + try std.testing.expect(!T.reserved("private")); + try std.testing.expect(!T.reserved("test")); + // `override` stays reserved (still lexes to error_unknown_keyword). + try std.testing.expect(T.s3Kind("override") == null); + try std.testing.expect(T.reserved("override")); + // Graduated keywords sit inside the contiguous keyword range so + // `isKeywordToken` covers them (tag-path contextual acceptance). + try std.testing.expect(isKeywordToken(.kw_const)); + try std.testing.expect(isKeywordToken(.kw_private)); + try std.testing.expect(isKeywordToken(.kw_test)); +} diff --git a/src/etch/types.zig b/src/etch/types.zig index 9e41e3b..b016056 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -175,7 +175,7 @@ pub const ResolvedType = union(enum) { }; /// Symbol entry in the file-local symbol table built by pass 1. -pub const SymbolKind = enum { component, resource, rule, type_alias, fn_, struct_, enum_, trait_, event_, data_, routine_, behavior_, quest_, dialogue_, ability_, motion_, widget_, locale_, effect_, audio_graph_, sequence_, anim_graph_, shader_ }; +pub const SymbolKind = enum { component, resource, rule, type_alias, fn_, struct_, enum_, trait_, event_, data_, routine_, behavior_, quest_, dialogue_, ability_, motion_, widget_, locale_, effect_, audio_graph_, sequence_, anim_graph_, shader_, const_, test_ }; const Symbol = struct { kind: SymbolKind, @@ -344,9 +344,9 @@ pub const TypeChecker = struct { /// second occurrence of a UUID is E1782. Both sets' keys reference the /// arenas' string pools, which `root.validateProject` keeps alive for the /// duration of the checks. - /// Visibility of an exported symbol (M1.0.7 E5, D-G). Wired but always - /// `.public` until `private` graduates (M1.0.8) — then the exports builder - /// sets `.private` and the binding path's `E0107` check becomes reachable. + /// Visibility of an exported symbol (M1.0.7 E5, D-G). Since `private` + /// graduated (M1.0.8) the exports builder sets `.private` from the decl's + /// `Item.visibility`, making the binding path's `E0107` check reachable. pub const Visibility = enum { public, private }; /// One exported top-level symbol of a module (M1.0.7 E5). `arena_index` is @@ -2781,6 +2781,24 @@ pub const TypeChecker = struct { const decl = self.arena.type_alias_decls.items[data]; try self.registerSymbol(.type_alias, decl.name, item_id, span); }, + .const_decl => { + // Top-level `const` (M1.0.8). Register the name (so it + // collides with a same-named symbol via E0101 and resolves + // cross-file once exported), then check the value is + // const-evaluable (E1101) and matches its declared type + // (E0200) — reusing the field-default const surface. + const decl = self.arena.const_decls.items[data]; + try self.registerSymbol(.const_, decl.name, item_id, span); + try self.checkConstValue(decl.value, decl.type_node); + }, + .test_decl => { + // Top-level `test` block (M1.0.8). Register a `test_` symbol + // (tracked, NOT exported — `buildExports` omits it). M1.0.8 + // is parse + validate + registration only; the body is not + // executed (no runtime surface — M1.0.9). + const decl = self.arena.test_decls.items[data]; + try self.registerSymbol(.test_, decl.name, item_id, span); + }, .data_decl => { // A `data` table (M0.8 E4 Level B) registers its name; the // table body (entries, spreads, entry-type conformance) is @@ -2912,8 +2930,8 @@ pub const TypeChecker = struct { /// - resolve the module path against `project.module_index`; a path that /// names no project file is `E0103 NotAModule`. /// - selective `{ X }`: look X up in the target module's exports — absent → - /// `E0104 UnknownExport`; private → `E0107` (dormant, D-G); else register - /// it in `imported_symbols` under its local name (alias if present). + /// `E0104 UnknownExport`; private → `E0107` (activated M1.0.8); else + /// register it in `imported_symbols` under its local name (alias if present). /// - module alias `as m` / bare `import a.b`: record the alias → target /// binding in `imported_aliases` (D-F; qualified `m.Type` resolution is /// E6+ additive, not done here). @@ -3288,6 +3306,25 @@ pub const TypeChecker = struct { } } + /// Validate a top-level `const` value (M1.0.8): it must be const-evaluable + /// (`E1101`) and its synthesized type must match the declared `: type` + /// (`E0200`). Reuses the field-default const surface (`isConstEvaluable` + + /// `namedTypeToResolved` + `synthExpr` + `literalTypeFits`); the only + /// difference is the const-specific diagnostic wording. + fn checkConstValue(self: *TypeChecker, value: NodeId, type_node: NodeId) !void { + if (!isConstEvaluable(self.arena, value)) { + try self.emit(.not_const_evaluable, .error_, self.arena.exprSpan(value), "const value must be a constant expression (literal, arithmetic on literals, or parenthesized)", .{}); + return; + } + const declared = self.namedTypeToResolved(type_node); + const actual = self.synthExpr(value, null); + if (declared == .builtin and actual == .builtin) { + if (!self.literalTypeFits(declared.builtin, value, actual.builtin)) { + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(value), "const value type does not match the declared type", .{}); + } + } + } + fn checkFieldDefault(self: *TypeChecker, value: NodeId, type_node: NodeId) !void { // Const-evaluability check. if (!isConstEvaluable(self.arena, value)) { @@ -6144,6 +6181,54 @@ test "type-checker emits E1101 on non-const default value" { try expectAnyCode(result.diagnostics.items, .not_const_evaluable); } +test "const type mismatch emits E0200" { + const gpa = std.testing.allocator; + // M1.0.8 — a `const` value whose type does not match the declared `: type`. + var result = try parseAndCheck(gpa, + \\const PORT: int = 3.14 + ); + defer result.deinit(gpa); + try expectAnyCode(result.diagnostics.items, .type_mismatch); +} + +test "non-const-evaluable const emits E1101" { + const gpa = std.testing.allocator; + // M1.0.8 — a `const` whose value is not const-evaluable (a bare ident). + var result = try parseAndCheck(gpa, + \\const LIMIT: int = some_var + ); + defer result.deinit(gpa); + try expectAnyCode(result.diagnostics.items, .not_const_evaluable); +} + +test "well-formed top-level const checks clean" { + const gpa = std.testing.allocator; + // The two acceptance fixtures: an ident-cased and a SCREAMING_SNAKE_CASE + // (type_ident) const, both literal values matching their declared types. + var result = try parseAndCheck(gpa, + \\const max_players: int = 16 + \\const ROOM_CAP: int = 8 + \\const PI: float = 3.14 + ); + defer result.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), result.diagnostics.items.len); +} + +test "test block registered as test_ symbol and not exported" { + const gpa = std.testing.allocator; + // Registration proof: two top-level `test` blocks with the same name collide + // through `registerSymbol` → E0101 DuplicateSymbol (so a `test` block IS a + // registered symbol). The "not exported" half is exercised cross-file in + // tests/etch/import_resolve_test.zig (a test name is not in module exports → + // E0104 on import). + var result = try parseAndCheck(gpa, + \\test "same name" { } + \\test "same name" { } + ); + defer result.deinit(gpa); + try expectAnyCode(result.diagnostics.items, .duplicate_symbol); +} + test "type-checker emits E0502 when an annotation is applied to the wrong target (D-S3-annot-applicability)" { const gpa = std.testing.allocator; diff --git a/tests/etch/import_resolve_test.zig b/tests/etch/import_resolve_test.zig index a9834d3..5cec0ee 100644 --- a/tests/etch/import_resolve_test.zig +++ b/tests/etch/import_resolve_test.zig @@ -110,3 +110,70 @@ test "import of a missing module errors (E0103)" { try etch.validateProject(gpa, &files, &diags); try std.testing.expectEqual(@as(usize, 1), countCode(diags.items, .not_a_module)); } + +test "selective import resolves a cross-file const (M1.0.7 debt cleared, M1.0.8)" { + const gpa = std.testing.allocator; + // `lib` declares a top-level `const`; `main` selectively imports it. The + // const is exported (public) and resolvable → no E0104 / E0107 / E0103. + // This clears the M1.0.7 deferred acceptance criterion (cross-file const). + const files = [_]etch.ProjectFile{ + .{ .name = "lib.etch", .source = "const ROOM_CAP: int = 8" }, + .{ .name = "main.etch", .source = "import lib { ROOM_CAP }" }, + }; + var diags: std.ArrayListUnmanaged(etch.Diagnostic) = .empty; + defer deinitDiags(gpa, &diags); + try etch.validateProject(gpa, &files, &diags); + try std.testing.expectEqual(@as(usize, 0), countCode(diags.items, .unknown_export)); + try std.testing.expectEqual(@as(usize, 0), countCode(diags.items, .import_private_item)); + try std.testing.expectEqual(@as(usize, 0), countCode(diags.items, .not_a_module)); +} + +test "import of a private item errors (E0107, M1.0.8 activation)" { + const gpa = std.testing.allocator; + // `lib` declares a `private component`; `main` selectively imports it. The + // item is in `lib`'s exports flagged `.private` → exactly one E0107 + // (activates the check wired-but-dormant since M1.0.7). + const files = [_]etch.ProjectFile{ + .{ .name = "lib.etch", .source = "private component Secret { hash: u32 = 0 }" }, + .{ .name = "main.etch", .source = "import lib { Secret }" }, + }; + var diags: std.ArrayListUnmanaged(etch.Diagnostic) = .empty; + defer deinitDiags(gpa, &diags); + try etch.validateProject(gpa, &files, &diags); + try std.testing.expectEqual(@as(usize, 1), countCode(diags.items, .import_private_item)); + try std.testing.expectEqual(@as(usize, 0), countCode(diags.items, .unknown_export)); +} + +test "a public declaration alongside a private one stays importable (M1.0.8)" { + const gpa = std.testing.allocator; + // Visibility is per-declaration: `Public` imports clean, `Secret` is E0107. + const files = [_]etch.ProjectFile{ + .{ .name = "lib.etch", .source = + \\private component Secret { hash: u32 = 0 } + \\component Public { value: int = 0 } + }, + .{ .name = "main.etch", .source = + \\import lib { Public } + \\import lib { Secret } + }, + }; + var diags: std.ArrayListUnmanaged(etch.Diagnostic) = .empty; + defer deinitDiags(gpa, &diags); + try etch.validateProject(gpa, &files, &diags); + try std.testing.expectEqual(@as(usize, 1), countCode(diags.items, .import_private_item)); + try std.testing.expectEqual(@as(usize, 0), countCode(diags.items, .unknown_export)); +} + +test "a test block is not exported (E0104 on import, M1.0.8)" { + const gpa = std.testing.allocator; + // `lib` declares a `test` block; it is registered intra-module but never + // exported → selectively importing its name is E0104 UnknownExport. + const files = [_]etch.ProjectFile{ + .{ .name = "lib.etch", .source = "test \"secret_test\" { }" }, + .{ .name = "main.etch", .source = "import lib { secret_test }" }, + }; + var diags: std.ArrayListUnmanaged(etch.Diagnostic) = .empty; + defer deinitDiags(gpa, &diags); + try etch.validateProject(gpa, &files, &diags); + try std.testing.expectEqual(@as(usize, 1), countCode(diags.items, .unknown_export)); +}