From ea7d45d058d1e5129d763deb2c27451b10a8539d Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 28 Jun 2026 21:32:09 +0200 Subject: [PATCH 01/13] docs(brief): add M1.0.7 milestone brief --- briefs/M1.0.7-cross-file-import.md | 206 +++++++++++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 briefs/M1.0.7-cross-file-import.md diff --git a/briefs/M1.0.7-cross-file-import.md b/briefs/M1.0.7-cross-file-import.md new file mode 100644 index 0000000..ce648b0 --- /dev/null +++ b/briefs/M1.0.7-cross-file-import.md @@ -0,0 +1,206 @@ +# M1.0.7 — Cross-file `import` (resolver pass-1) + +> **Status:** PLANNED +> **Phase:** 1 +> **Branch:** `phase-1/etch/cross-file-import` +> **Tag (set after merge by Guy):** `v0.10.7-cross-file-import` +> **Dependencies:** M0.8 (grammar v0.6: the `declaration` dispatch + `parseTopLevel`, the `non_s3_keywords` reserve list that holds `import`, the `kw_as` token from casts), M0.9 (E2-B cross-file `validateProject` + `TypeChecker.ProjectContext` byte-keyed `prefabs`/`uuids` indexes + `checkProject` — the seam this milestone extends), M1.0.6 (`validatePrefab` / `checkComponentInstance` and `.prefab.etch` cooking — the E1793 site this milestone unblocks) +> **Open date:** 2026-06-28 +> **Close date:** — + +--- + +# FROZEN SECTION + +*Authored by Claude.ai. Not modifiable by Claude Code outside a Claude.ai round-trip (see § Accepted deviations).* + +## Context + +First milestone after the scene track. It implements **cross-file `import`** in Etch: the module graph, its topological ordering, a project-wide exports table, and the binding of imported items into a file's name resolution — in particular resolving component **`TYPE_IDENT`s across modules**. It validates the plan testable: *"a file imports a type/const from another, cross-file resolution OK"*, and unblocks the **E1793 PrefabComponentTypeUnknown false positive**: a `.prefab.etch` cannot declare its components (typed-extension cardinality = exactly one `prefab`) and must `import` them, so until imports resolve cross-file, a valid prefab's component references wrongly trip E1793. + +**Scope-defining finding (ground-truthed at `v0.10.6`, governs this brief): `import` is unimplemented end to end — parser included.** It is **not** "resolver pass-1" alone. Today `import` is a deliberate fail-loud parse error and produces no AST; the resolver cannot resolve what the parser refuses. The milestone graduates `import` from the lexer up, then does the resolver work. This is the M1.0.6 lesson applied: the real code surface prevails, verified at the tag. + +**Verified seams (read against tag `v0.10.6-prefabs-crossrefs-extensions` — governs the implementation):** +- **Lexer/token:** `import` lives in `token.non_s3_keywords` (`src/etch/token.zig`, with `const`/`private`/`test`/`override`); the lexer maps any `non_s3_keywords` lexeme to the token `.error_unknown_keyword` (`src/etch/lexer.zig:267-270`). There is **no `kw_import` token**. The `kw_as` token already exists (casts, `src/etch/token.zig:54`, `s3_keywords` table) → import reuses it. +- **Parser:** `.error_unknown_keyword` → hard parse error *"Etch keyword '…' is not supported in S3 (UnsupportedConstructInS3)"* (`src/etch/parser.zig:639`). `parseTopLevel` (`:644`) has **no `kw_import` arm**; the `else` (`:689`) emits "expected top-level declaration …". `import` is **also already listed as a top-level sync token in the parser spec** (`etch-parser.md §11.2`) — the recovery sets must gain `kw_import` (`:629` + resync). +- **AST:** `import_decl` is an `ItemKind` enum variant (`src/etch/ast.zig:134`) but **reserved-unused** — no `ImportDecl` struct, no `import_decls` arena array, no item representation (sole occurrence in the tree). +- **Resolver (`src/etch/types.zig`):** zero import handling (the word `import` is absent from the file). `pass1Collect` (`:2562`) walks `self.arena.items` (one file) and calls `registerSymbol` (`:2789`, emits `duplicate_symbol`/E0101 on collision); `self.symbols` (`:275`) is **per-`TypeChecker` = per-file**. +- **Cross-file seam:** `TypeChecker.ProjectContext` (`:321`) + `checkProject` (`:364`) exist (M0.9 E2-B) but carry **only** byte-keyed `prefabs` + `uuids` (for E1782/E1786/E1791) — **no exports/symbol table**. StringIds are per-arena, so cross-file keys on string **bytes** (the established M0.9 pattern). +- **E1793 site:** `checkComponentInstance` does `self.symbols.get(ci.type_name)` then `self.arena.component_decls.items[sym.item_id]` — it assumes the component decl is **in the current arena**. For an imported component the decl lives in **another arena** → resolving the name is not enough; the field checks (E1794/E1795) must run **cross-arena** (byte-keyed). This is the one structural change (see D-E). +- **Orchestration:** `root.validateProject` (`src/etch/root.zig`) parses every file (arenas kept alive), builds the `prefabs`/`uuids` indexes, then loops `checkProject` **in input order — no module graph, no topological sort**. `ProjectFile` is `{ name, source }` (`:184`) — `name` is the path, available for module-path derivation. + +## Design decisions (frozen) + +Taken with Guy before coding. They are the contract; do not re-litigate. The three KB reconciliations (D-B/D-C/D-D) are **already delivered as complete re-uploaded files** — E1 confirms they are in place, it does not re-derive them. + +**D-A — `import` graduates parser-up (not resolver-only).** Remove `import` from `token.non_s3_keywords`; add a `kw_import` token to the `TokenKind` enum + `s3_keywords` table. Add an `ImportDecl` AST struct + `import_decls` arena array. Add `parseImportDecl` + a `parseTopLevel` arm + recovery-set membership. Only **then** is there an AST for the resolver to act on. Only `import` graduates here — `const`/`private`/`test`/`override` stay reserved for M1.0.8. + +**D-B — E0101 conflict resolved → `ImportCycle = E0108` (KB re-uploaded).** The spec had assigned `E0101 ImportCycle`; the shipped code uses `E0101 = DuplicateSymbol` (tested since M0.x). Principle "diagnostic codes stable cross-version" wins: **`E0101` stays `DuplicateSymbol`**; **`ImportCycle` is `E0108`** (free, in the E01XX resolver range). Files patched: `etch-diagnostics.md` (§4 table + §24.1 resolution note), `etch-resolver-types.md` (§2.3). Do **not** reintroduce `E0101 = ImportCycle`. + +**D-C — `import` is allowed in typed-extension files (KB re-uploaded).** `etch-grammar.md §21.2` self-contradicted (`import` listed as forbidden in `.scene.etch`/`.prefab.etch` *and* required for type refs). Resolution (patched): `import` is permitted at the top of typed-extension files and is **excluded from the E0858 cardinality count**; the "exactly one construct" rule applies to the **main referenceable construct** (`scene`/`prefab`/`layer`/`world`). This is a prerequisite of the E1793 unblock. + +**D-D — `import_item` grammar widened to `( IDENT | TYPE_IDENT )` (KB re-uploaded).** `etch-grammar.md §5.2` had `import_item = IDENT , […]`, but imported items are mostly `TYPE_IDENT` (`Vec3`, `Health`) — the spec's own examples were ungrammatical. Patched to `import_item = ( IDENT | TYPE_IDENT ) , [ "as" , ( IDENT | TYPE_IDENT ) ]`. `parseImportDecl` accepts both `.ident` and `.type_ident` tokens as items. + +**D-E — Project-wide exports index + cross-arena component resolution (the structural core).** Extend the M0.9 byte-keyed `ProjectContext` pattern with a **byte-keyed exports index**: exported (non-`private`) top-level name → `{ kind, defining-arena ref, item_id }`, built once over all parsed files in `validateProject`. Per file, bind its imports against the index (selective import → the items enter that file's resolution under their local name; module-alias → an `imported_alias` entry recording the target `ModuleId`). Then `checkComponentInstance` / `checkInstanceField` must, when the component type resolves to an **imported** symbol, fetch the component decl from its **defining arena** and field-check **cross-arena** (compare field-name **bytes**, not StringIds). This is the one non-trivial refactor — those functions currently hard-assume `self.arena`. **If the cross-arena field-check refactor balloons beyond the slice, that is a Case-2 blocker (stop, journal, return to Claude.ai) — not an improvised hack.** + +**D-F — Module-alias qualified resolution (`m.Type`) is DEFERRED (additive).** The selective-import path (forms 2/4 — `import a.b { X }`, `import a.b { X as Y }`) brings bare names into scope and **is the critical path for the E1793 unblock and the testable**. The descending `Path` walk for module aliases (`import a.b as m` → `m.Type`, `etch-resolver-types.md §3.1` "Path qualified") is **out of this milestone**. E5 still **records the alias binding** (`imported_alias { target: ModuleId }`), so adding the walk later is purely additive — no refactor of call sites (design-deferral rule satisfied). The two module-alias import **forms still parse** in E3 (D-A); only their qualified *resolution* is deferred. + +**D-G — `E0107 ImportPrivateItem` is wired but dormant until M1.0.8.** `private` does not graduate until M1.0.8, so in M1.0.7 every top-level item is public and the exports index = all top-level items. The exports index filters on a visibility flag defaulting to public; `E0107` is added to the catalogue and the binding path checks it, but it is unreachable until `private` lands (then M1.0.8 is purely additive). + +## Scope + +**E1 — Seam reconfirmation + KB-reconciliation check + lexer/token graduation.** +- Re-read the Verified seams against the branch head (no assumption — past mis-rulings came from skipping this). Confirm the three reconciled KB files (D-B/D-C/D-D) are in place; confirm `kw_as` exists. +- `token.zig`: remove `import` from `non_s3_keywords`; add `kw_import` to the `TokenKind` enum and the `s3_keywords` lexeme table. `lexer.zig`: `import` now lexes as `kw_import`; update the lexer test "graduated from reserved" to cover it. +- → **STOP** — push, await review + GO. + +**E2 — AST `ImportDecl`.** +- `ast.zig`: add `ImportDecl` (module-path StringId run; optional module alias StringId; import-item run, each item = imported name + optional local-alias name) + an `import_decls` arena array + wire the existing `import_decl` `ItemKind` to it. Follow the existing arena `_start`/`_len`/extra-array pattern used by other decls (e.g. component fields, prefab entities) — read one before choosing the layout. +- → **STOP** — push, await review + GO. + +**E3 — Parser: `parseImportDecl` (4 forms) + dispatch + recovery.** +- `parser.zig`: `parseImportDecl` handling `import a.b`, `import a.b { X, Y }`, `import a.b as m`, `import a.b { X as Y }`; items accept `.ident` **and** `.type_ident` (D-D). Add the `kw_import` arm to `parseTopLevel` (`:644`); add `kw_import` to the top-level sync set (`:629`) and the resync path. A malformed import recovers (ErrorNode/sync), it no longer hits the `else` (`:689`). +- → **STOP** — push, await review + GO. + +**E4 — Module graph + topological order + cycle detection (E0108).** +- `diagnostics.zig`: add `import_cycle` (E0108) to `DiagnosticCode` + `code()`/`name()` switches. +- `root.zig` `validateProject`: derive each file's module path from `ProjectFile.name` (path under `src/`, `.` separators); build a module-path → arena map and the directed import-dependency graph; topologically sort it; on a cycle emit **E0108 ImportCycle** pointing at the module that closes the loop. The per-file checks (E6) then run in topo order so a module sees its dependencies' exports already collected. +- → **STOP** — push, await review + GO. + +**E5 — Project-wide exports index + import binding (E0102/E0103/E0104/E0107).** +- `diagnostics.zig`: add `not_a_module` (E0103), `unknown_export` (E0104), `import_private_item` (E0107). (`undefined_symbol`/E0102 already exists.) +- `types.zig`: extend `ProjectContext` with a byte-keyed exports index `{ name_bytes → { kind, arena_ref, item_id } }` (exclude `private` via the visibility flag, all-public for now — D-G). In `validateProject`, build it over all parsed files. Per file, resolve its `ImportDecl`s against the index: selective items become in-scope symbols under their local name; a module alias becomes an `imported_alias` recording the target `ModuleId` (binding only — qualified resolution is D-F). Emit `E0104` (item absent from the target's exports), `E0103` (path segment is not a module), `E0107` (dormant — D-G). Thread the per-file imported set into `checkProject`. +- → **STOP** — push, await review + GO. + +**E6 — Apply imports to `TYPE_IDENT` + cross-arena component check (E1793 unblock) + CLAUDE.md.** +- `types.zig`: `TYPE_IDENT` / `Ident` resolution consults the file's imported set (selective imports). `checkComponentInstance` / `checkInstanceField` (D-E): when a component type resolves to an imported symbol, fetch the component decl from its **defining arena** and field-check **cross-arena** by field-name bytes. Result: a `.prefab.etch` importing its components validates clean under `validateProject`; **E1793 fires only for genuinely-unknown component types**. (`undefined_symbol`/E0102 stays the code for an unresolved bare `TYPE_IDENT`.) +- `CLAUDE.md` §3.4 update on the branch (`docs(claude-md): update for M1.0.7`): État courant row (cross-file import live; pass-1 resolves imports cross-module), Tags row `v0.10.7-cross-file-import`, validated hypotheses (the byte-keyed exports index extends the M0.9 ProjectContext pattern; E1793 unblocked), open-decisions delta (module-alias qualified resolution + `E0107` activation tracked for later), Last-updated date. +- → **STOP** — push, await review + GO. + +## Out-of-scope + +- **Module-alias qualified-`Path` resolution (`m.Type`)** — D-F. The two `as`-module forms parse and bind (alias recorded), but resolving `m.Type` member-access is a later additive slice. Selective import is the milestone's resolution path. +- **`const` / `private` / `test` / `override` graduation** — M1.0.8. Only `import` leaves `non_s3_keywords` here. `E0107 ImportPrivateItem` is wired-but-dormant (D-G). +- **Cross-scene / cross-project identity** — the exports index is built over the files passed to `validateProject`; no global registry. +- **Incremental pass-1 caching (LS / hot-reload)** — `etch-resolver-types.md §1` "Incrémentalité". M1.0.7 is the batch `validateProject` path only. +- **HIR lowering of imports** — imports are a resolve-time construct; nothing lowers. The S5 codegen surface is untouched. +- **Re-exports** — the spec forbids automatic re-exports; nothing to build. +- **`E0859 ModulePathNamingConvention`** (snake_case import-path segments) — a `weld check` NamingConvention code (E085X range, like `E0858`); the resolver builds the graph but does not enforce path naming here. +- **`E0105`/`E0106` (enum-variant resolution / `AmbiguousEnumVariant`)** — enum-shorthand, not imports; already shipped / separate. +- **Post-M1.0.6 KB reconciliation** (`engine-scene-serialization §4` binary shape, the `parent: uuid` / `Light.color =` / `layer:` example drifts) — separate conversation per the kickoff; **do not** fold into this milestone. + +## Specs to read first + +1. `etch-resolver-types.md` — §2 (symbol table / scopes / §2.3 modules + imports + cycle = `E0108`), §3.1 (name resolution: `TYPE_IDENT` = global table + imports, `Path` qualified = the D-F deferred walk), §1 (two-pass overview, compilation set). PRIMARY. +2. `etch-grammar.md` — §5.2 (`import_decl` / `module_path` / `import_spec` / `import_item` — reconciled), §1.3 (keywords `import` / `as` / `private`), §21.2 (typed-extension import allowance — reconciled). +3. `etch-reference-part1.md` — §1.1 (file = module, path under `src/`), §1.2 (the 4 import forms), §1.3 (visibility: public-default / `private`), §1.4 (top-level declarations). +4. `etch-diagnostics.md` — §4 (E01XX imports: `E0101 DuplicateSymbol`, `E0102 UndefinedSymbol`, `E0103 NotAModule`, `E0104 UnknownExport`, `E0107 ImportPrivateItem`, `E0108 ImportCycle` — reconciled), §24.1 (the E0101 resolution note). +5. `etch-parser.md` — §11.2 (sync points — `import` is already a listed top-level sync token; the recovery sets gain `kw_import`). +6. `engine-phase-1-plan.md` — the M1.0.7 line. +7. `etch-validation-ecs.md` — §24 (`prefab` — `E1793 PrefabComponentTypeUnknown` context), §23 (`scene`). +8. `engine-zig-conventions.md` — §13 (test rooting / lazy-analysis guard, so `tests/etch/` runs), §16/§19 (conventions, POD `extern struct`). + +## Files to create or modify + +- `src/etch/token.zig` — **edit** (E1) — remove `import` from `non_s3_keywords`; add `kw_import` to the `TokenKind` enum + `s3_keywords` lexeme table. +- `src/etch/lexer.zig` — **edit** (E1) — `import` now matched by the `s3_keywords` loop → `kw_import`; update the "graduated from reserved" lexer test. +- `src/etch/ast.zig` — **edit** (E2) — `ImportDecl` struct + `import_decls` arena array; wire the `import_decl` `ItemKind`. +- `src/etch/parser.zig` — **edit** (E3) — `parseImportDecl` (4 forms, items `.ident` + `.type_ident`); `parseTopLevel` arm (`:644`); `kw_import` in the sync set (`:629`) + resync. +- `src/etch/diagnostics.zig` — **edit** (E4/E5) — add `import_cycle` (E0108), `not_a_module` (E0103), `unknown_export` (E0104), `import_private_item` (E0107) to `DiagnosticCode` + `code()`/`name()` switches. +- `src/etch/root.zig` — **edit** (E4/E5) — `validateProject`: module-path derivation from `ProjectFile.name`, import graph, topological sort + `E0108`, exports-index build, per-file import binding, drive `checkProject` in topo order. +- `src/etch/types.zig` — **edit** (E5/E6) — extend `ProjectContext` with the byte-keyed exports index + visibility flag; resolve `TYPE_IDENT`/`Ident` against the file's imported set; `checkComponentInstance`/`checkInstanceField` cross-arena resolution + byte-keyed field check (D-E); emit `E0103`/`E0104`/`E0107`. +- `tests/etch/import_parse_test.zig` — **create** (E3) — the 4 import forms parse into `ImportDecl`; a malformed import recovers (ErrorNode/sync, no `UnsupportedConstructInS3`). +- `tests/etch/import_resolve_test.zig` — **create** (E5/E6) — selective cross-file import of a component type **and** a `const` resolves; `import a.b { Absent }` → `E0104`; import cycle A↔B → `E0108`. (`E0107` noted dormant — D-G.) +- `tests/etch/crossfile_prefab_import_test.zig` — **create** (E6) — a `.prefab.etch` that imports its component types validates clean under `validateProject` (no `E1793`); a prefab referencing a genuinely-undeclared component still → `E1793`. +- `build.zig` — **edit** — wire the new `tests/etch/` targets (rooted per §13). +- `CLAUDE.md` — **edit** (E6) — §3.4, via `docs(claude-md): update for M1.0.7`. + +## Acceptance criteria + +### Tests + +- `tests/etch/import_parse_test.zig` — `test "all four import forms parse"` — `import a.b`, `import a.b { X, Y }`, `import a.b as m`, `import a.b { X as Y }` each build an `ImportDecl` with the expected path / alias / items. `test "import accepts TYPE_IDENT and IDENT items"` — `import a.b { Health, gravity }` parses both. `test "malformed import recovers"` — a broken `import` yields a diagnostic and the next top-level decl still parses (no `UnsupportedConstructInS3`). +- `tests/etch/import_resolve_test.zig` — `test "selective import resolves a cross-file type"` — file B `import a { Health }` references `Health` declared in file A; resolves with no diagnostic under `validateProject`. `test "selective import resolves a cross-file const"`. `test "unknown export errors"` — `import a { Nope }` → `E0104`. `test "import cycle errors"` — A imports B, B imports A → `E0108` at the closing module. +- `tests/etch/crossfile_prefab_import_test.zig` — `test "prefab importing its components validates clean"` — a `.prefab.etch` that `import`s `Transform`/`Health` from another file validates under `validateProject` with **no E1793**. `test "prefab with an undeclared component still errors"` — referencing a component declared nowhere → `E1793`. + +### Benchmarks + +- None new. The module graph + topo-sort + exports index are `O(files + imports + exports)` over a small set, off any runtime hot path. Note in Closing notes if `validateProject` time moves materially vs M1.0.6; do not gate on a threshold. + +### Observable behaviour + +- A runnable scenario (extend the `validateProject` harness / a test with output): a two-file project where one file `import`s a component type and a `const` from the other validates with zero diagnostics; a `.prefab.etch` that imports its component types validates clean. Log the diagnostic count (0) for each. + +### CI + +- `zig build` clean, zero warnings, on the configured matrix. +- `zig build test` green (Debug + ReleaseSafe). +- `zig fmt --check` green. +- `zig build lint` green (when the custom linter exists). +- `commit-msg` hook green on every commit of the branch. + +## Conventions + +- **Branch**: `phase-1/etch/cross-file-import` +- **Final tag**: `v0.10.7-cross-file-import` +- **PR title**: `Phase 1 / Etch / Cross-file import (resolver pass-1)` +- **Commit convention**: Conventional Commits (cf. `engine-development-workflow.md` §4.3) +- **Merge strategy**: squash-and-merge (cf. `engine-development-workflow.md` §4.6) + +## Notes + +- **Code is ground truth.** Re-read the Verified seams at the branch head before touching them. `import` is a parse error today — the graduation (D-A) is a prerequisite, not an afterthought. +- **`import` graduates, nothing else.** Only `import` leaves `non_s3_keywords`; `const`/`private`/`test`/`override` stay reserved (M1.0.8). Touching them is out-of-scope. +- **Do not reintroduce `E0101 = ImportCycle`.** `E0101` is `DuplicateSymbol` (shipped); `ImportCycle` is `E0108` (D-B). The KB is already reconciled. +- **Cross-arena field checking is the one structural risk (D-E).** `checkComponentInstance`/`checkInstanceField` assume `self.arena`; imported components live elsewhere. Resolve to `{arena, item_id}` and compare field-name **bytes**. If the refactor balloons, that is a Case-2 blocker — stop and return to Claude.ai. +- **Module-alias qualified resolution is deferred but pre-wired (D-F).** Record the `imported_alias { ModuleId }` binding in E5 even though `m.Type` does not resolve yet, so the later addition is purely additive. +- **`E0107` is dormant (D-G).** Wire it; it is unreachable until `private` graduates (M1.0.8). +- **Byte-keyed everything cross-file.** StringIds are per-arena; the exports index and the cross-arena field check key on string bytes, exactly like the M0.9 `prefabs`/`uuids` indexes. +- **Two blockers ⇒ re-scope signal.** Per the prompt protocol, two distinct blockers mean the milestone is under-specified or too large; flag it rather than push through. + +--- + +# LIVING SECTION + +*Maintained by Claude Code during the milestone. The journal is not a marketing report: it serves review and post-mortem debug.* + +## Specs read + +*To be checked off before any production code. Confirms the spec was ingested fully, not skimmed.* + +- [ ] `etch-resolver-types.md` (§1, §2, §3.1) — read +- [ ] `etch-grammar.md` (§5.2, §1.3, §21.2) — read +- [ ] `etch-reference-part1.md` (§1.1–§1.4) — read +- [ ] `etch-diagnostics.md` (§4, §24.1) — read +- [ ] `etch-parser.md` (§11.2) — read +- [ ] `engine-phase-1-plan.md` (M1.0.7) — read +- [ ] `etch-validation-ecs.md` (§23, §24) — read +- [ ] `engine-zig-conventions.md` (§13, §16, §19) — read + +## Execution journal + +*One entry per logical work sequence (an objective met, a green test, a blocker). Chronological. 1–3 lines each.* + +- + +## Accepted deviations + +*Changes to the FROZEN SECTION made mid-milestone after a Claude.ai round-trip. Each references the commit that enacts it. Empty at the end = nominal.* + +- + +## Blockers encountered + +*Blockers that required a Claude.ai round-trip (cf. `engine-development-workflow.md` §2.4). 2+ distinct blockers = re-scope signal.* + +- — resolved by or + +## Closing notes + +*Filled at Status → CLOSED, just before opening the PR.* + +- **What worked**: +- **What deviated from the original spec**: +- **What to flag explicitly in review**: +- **Final measures** (perf, binary size, compile time, whatever is relevant): +- **Residual risk / tech debt left deliberately**: From 63314ee885c8ee17278da544d862b52141be906f Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 28 Jun 2026 21:32:23 +0200 Subject: [PATCH 02/13] docs(brief): confirm specs read for M1.0.7 --- briefs/M1.0.7-cross-file-import.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/briefs/M1.0.7-cross-file-import.md b/briefs/M1.0.7-cross-file-import.md index ce648b0..3f2b750 100644 --- a/briefs/M1.0.7-cross-file-import.md +++ b/briefs/M1.0.7-cross-file-import.md @@ -168,14 +168,14 @@ Taken with Guy before coding. They are the contract; do not re-litigate. The thr *To be checked off before any production code. Confirms the spec was ingested fully, not skimmed.* -- [ ] `etch-resolver-types.md` (§1, §2, §3.1) — read -- [ ] `etch-grammar.md` (§5.2, §1.3, §21.2) — read -- [ ] `etch-reference-part1.md` (§1.1–§1.4) — read -- [ ] `etch-diagnostics.md` (§4, §24.1) — read -- [ ] `etch-parser.md` (§11.2) — read -- [ ] `engine-phase-1-plan.md` (M1.0.7) — read -- [ ] `etch-validation-ecs.md` (§23, §24) — read -- [ ] `engine-zig-conventions.md` (§13, §16, §19) — read +- [x] `etch-resolver-types.md` (§1, §2, §3.1) — read 2026-06-28 21:30 +- [x] `etch-grammar.md` (§5.2, §1.3, §21.2) — read 2026-06-28 21:30 +- [x] `etch-reference-part1.md` (§1.1–§1.4) — read 2026-06-28 21:30 +- [x] `etch-diagnostics.md` (§4, §24.1) — read 2026-06-28 21:30 +- [x] `etch-parser.md` (§11.2) — read 2026-06-28 21:30 +- [x] `engine-phase-1-plan.md` (M1.0.7) — read 2026-06-28 21:30 +- [x] `etch-validation-ecs.md` (§23, §24) — read 2026-06-28 21:30 +- [x] `engine-zig-conventions.md` (§13, §16, §19) — read 2026-06-28 21:30 ## Execution journal From 5bf667936b7c7aaad8552b8b8d686ada1d042614 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 28 Jun 2026 21:32:33 +0200 Subject: [PATCH 03/13] docs(brief): activate M1.0.7 --- briefs/M1.0.7-cross-file-import.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/briefs/M1.0.7-cross-file-import.md b/briefs/M1.0.7-cross-file-import.md index 3f2b750..8f00407 100644 --- a/briefs/M1.0.7-cross-file-import.md +++ b/briefs/M1.0.7-cross-file-import.md @@ -1,6 +1,6 @@ # M1.0.7 — Cross-file `import` (resolver pass-1) -> **Status:** PLANNED +> **Status:** ACTIVE > **Phase:** 1 > **Branch:** `phase-1/etch/cross-file-import` > **Tag (set after merge by Guy):** `v0.10.7-cross-file-import` From 11f6622d07b9bbb82943a19dbbe6c42ccfe780b7 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 28 Jun 2026 21:35:05 +0200 Subject: [PATCH 04/13] feat(etch): graduate import to kw_import token (M1.0.7 E1) --- briefs/M1.0.7-cross-file-import.md | 4 +++- src/etch/lexer.zig | 19 +++++++++++++++---- src/etch/token.zig | 6 ++++-- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/briefs/M1.0.7-cross-file-import.md b/briefs/M1.0.7-cross-file-import.md index 8f00407..7a2a2ce 100644 --- a/briefs/M1.0.7-cross-file-import.md +++ b/briefs/M1.0.7-cross-file-import.md @@ -181,7 +181,9 @@ Taken with Guy before coding. They are the contract; do not re-litigate. The thr *One entry per logical work sequence (an objective met, a green test, a blocker). Chronological. 1–3 lines each.* -- +- 2026-06-28 21:30 — Étape 0–2 done: branch `phase-1/etch/cross-file-import` off `main` (98ea989), brief copied verbatim, 8 specs read in full, Status → ACTIVE. +- 2026-06-28 21:40 — E1 seam reconfirmation: confirmed `import` in `non_s3_keywords` (token.zig:296 block), `kw_as` present (token.zig:54/217), no `kw_import`, lexer table-driven (lexer.zig:260-271). KB reconciliations confirmed in the read specs: D-B (`E0101 DuplicateSymbol` + `E0108 ImportCycle`, etch-diagnostics §4/§24.1), D-C (`import` allowed in typed-extension files, etch-grammar §21.2), D-D (`import_item = (IDENT|TYPE_IDENT)`, etch-grammar §5.2). +- 2026-06-28 21:40 — E1 graduation: added `kw_import` to `TokenKind` (within kw_let..kw_f64 range) + `s3_keywords`; removed `import` from `non_s3_keywords`. Retargeted the "unknown keyword" lexer test to `const`; added "graduated from reserved" test for `import`. `zig test src/etch/lexer.zig` 17/17 green; `zig build` clean (no exhaustive-switch breakage); `zig fmt --check` green. E1 STOP — awaiting review + GO. ## Accepted deviations diff --git a/src/etch/lexer.zig b/src/etch/lexer.zig index 8bcea73..d0d184a 100644 --- a/src/etch/lexer.zig +++ b/src/etch/lexer.zig @@ -527,16 +527,27 @@ 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, - // and `scene`/`prefab` graduated with the E7 Level-C scene slice (the last - // two construct keywords of the v0.6 grammar). The remaining reserved - // top-level keyword is `import` (module system, Phase 1+). - var lex = Lexer.init("fn ability import"); + // `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"); 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 recognizes the M1.0.7 import keyword (graduated from reserved)" { + const gpa = std.testing.allocator; + var lex = Lexer.init("import a.b"); + defer lex.deinit(gpa); + try expectKind(&lex, gpa, .kw_import); + try expectKind(&lex, gpa, .ident); // a + try expectKind(&lex, gpa, .dot); + try expectKind(&lex, gpa, .ident); // b + try expectKind(&lex, gpa, .eof); +} + test "lexer recognizes the M0.8 E7 scene + prefab keywords (graduated from reserved)" { const gpa = std.testing.allocator; var lex = Lexer.init("scene prefab"); diff --git a/src/etch/token.zig b/src/etch/token.zig index e8a1334..404b27c 100644 --- a/src/etch/token.zig +++ b/src/etch/token.zig @@ -106,6 +106,7 @@ pub const TokenKind = enum { kw_shader, // shader declaration (M0.8 E6 Level B render — vertex/fragment stages, shader-mode body validation) 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) // ── Primitive type keywords (lexed as kw_type_*) ── kw_int, @@ -269,6 +270,7 @@ pub const s3_keywords = [_]KeywordEntry{ .{ .lexeme = "shader", .kind = .kw_shader }, .{ .lexeme = "scene", .kind = .kw_scene }, .{ .lexeme = "prefab", .kind = .kw_prefab }, + .{ .lexeme = "import", .kind = .kw_import }, .{ .lexeme = "true", .kind = .bool_literal }, .{ .lexeme = "false", .kind = .bool_literal }, .{ .lexeme = "int", .kind = .kw_int }, @@ -298,8 +300,8 @@ pub const non_s3_keywords = [_][]const u8{ // call mechanism; `struct` / `impl` / `enum` / `trait` with E2 block 3; // `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", + // 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", From 7894c2e11f35b95bacc85986f041122804ed8a94 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 28 Jun 2026 21:46:42 +0200 Subject: [PATCH 05/13] feat(etch): add ImportDecl AST node + arena slabs (M1.0.7 E2) --- briefs/M1.0.7-cross-file-import.md | 1 + src/etch/ast.zig | 50 ++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/briefs/M1.0.7-cross-file-import.md b/briefs/M1.0.7-cross-file-import.md index 7a2a2ce..d203d72 100644 --- a/briefs/M1.0.7-cross-file-import.md +++ b/briefs/M1.0.7-cross-file-import.md @@ -184,6 +184,7 @@ Taken with Guy before coding. They are the contract; do not re-litigate. The thr - 2026-06-28 21:30 — Étape 0–2 done: branch `phase-1/etch/cross-file-import` off `main` (98ea989), brief copied verbatim, 8 specs read in full, Status → ACTIVE. - 2026-06-28 21:40 — E1 seam reconfirmation: confirmed `import` in `non_s3_keywords` (token.zig:296 block), `kw_as` present (token.zig:54/217), no `kw_import`, lexer table-driven (lexer.zig:260-271). KB reconciliations confirmed in the read specs: D-B (`E0101 DuplicateSymbol` + `E0108 ImportCycle`, etch-diagnostics §4/§24.1), D-C (`import` allowed in typed-extension files, etch-grammar §21.2), D-D (`import_item = (IDENT|TYPE_IDENT)`, etch-grammar §5.2). - 2026-06-28 21:40 — E1 graduation: added `kw_import` to `TokenKind` (within kw_let..kw_f64 range) + `s3_keywords`; removed `import` from `non_s3_keywords`. Retargeted the "unknown keyword" lexer test to `const`; added "graduated from reserved" test for `import`. `zig test src/etch/lexer.zig` 17/17 green; `zig build` clean (no exhaustive-switch breakage); `zig fmt --check` green. E1 STOP — awaiting review + GO. +- 2026-06-28 21:50 — E1 reviewed + GO (diff 11f6622). E2 AST: read the arena patterns (ComponentDecl `_start`/`_len`, PrefabDecl, the `tag_path_segs`/`prefab_requires` StringId-run precedent). Added `ImportItem {name, alias}` + `ImportDecl {path run, module_alias, items run}` structs, 3 slabs (`import_decls`/`import_path_segs`/`import_items`) + deinit, `addImportDecl` helper. Wired to the pre-existing `ItemKind.import_decl` variant (additive; span carried at the Item slab). D-D accommodated (name/alias are StringIds — IDENT and TYPE_IDENT both fit). `zig build` clean; `zig test src/etch/ast.zig` 6/6; `zig fmt --check` green. E2 STOP — awaiting review + GO. ## Accepted deviations diff --git a/src/etch/ast.zig b/src/etch/ast.zig index b8a0c0a..a57f0b6 100644 --- a/src/etch/ast.zig +++ b/src/etch/ast.zig @@ -324,6 +324,37 @@ pub const Field = struct { annotations_len: u32, }; +/// One `import_item` (`( IDENT | TYPE_IDENT ) [ "as" ( IDENT | TYPE_IDENT ) ]`, +/// `etch-grammar.md` §5.2, reconciled D-D). Imported items are mostly +/// `TYPE_IDENT` (`Vec3`, `Health`) but a bare `IDENT` (`gravity`) is equally +/// legal — the AST stores the interned name, so the token-kind distinction is a +/// parse concern (E3) and this shape accommodates both. `alias` is the optional +/// local-alias name (`as Y`), `0` when absent. +pub const ImportItem = struct { + name: StringId, // imported item name (IDENT or TYPE_IDENT) + alias: StringId, // local alias (`as Y`), 0 if absent +}; + +/// Side-slab entry for an `import` directive (M1.0.7, `etch-grammar.md` §5.2: +/// `import_decl = "import" module_path [ import_spec ]`). The module path is a +/// `(start, len)` run of `arena.import_path_segs` (≥1 IDENT segment, e.g. +/// `core`, `math` — the `tag_path_segs` precedent). `module_alias` carries the +/// `as m` whole-module alias (`0` when absent — a bare `import a.b` has implicit +/// alias = last path segment, derived at resolve). `items` is a run of +/// `arena.import_items` for the selective form (`import a.b { X, Y }`); +/// `items_len == 0` for the whole-module forms. The four grammar forms map to: +/// (1) `import a.b` → alias 0, items 0; (2) `import a.b { X, Y }` → items > 0; +/// (3) `import a.b as m` → module_alias set; (4) `import a.b { X as Y }` → items +/// with per-item alias. Module-alias qualified resolution (`m.Type`) is deferred +/// (D-F); E5 still records the alias binding so the later walk is purely additive. +pub const ImportDecl = struct { + path_start: u32, // index into `arena.import_path_segs` + path_len: u32, // ≥ 1 + module_alias: StringId, // `as m` alias (0 if absent or selective form) + items_start: u32, // index into `arena.import_items` + items_len: u32, // 0 for the whole-module forms (1 and 3) +}; + /// Side-slab entry for a `component` declaration: name + range into /// `arena.fields` + annotation range. pub const ComponentDecl = struct { @@ -2249,6 +2280,13 @@ pub const AstArena = struct { // Side slabs. fields: std.ArrayListUnmanaged(Field) = .empty, + // M1.0.7 cross-file import. `import_decls` holds one entry per `import` + // directive; `import_path_segs` is the flat module-path segment pool (a + // `StringId` run, the `tag_path_segs` precedent); `import_items` holds the + // selective `{ X, Y }` items. + import_decls: std.ArrayListUnmanaged(ImportDecl) = .empty, + import_path_segs: std.ArrayListUnmanaged(StringId) = .empty, + import_items: std.ArrayListUnmanaged(ImportItem) = .empty, component_decls: std.ArrayListUnmanaged(ComponentDecl) = .empty, resource_decls: std.ArrayListUnmanaged(ResourceDecl) = .empty, event_decls: std.ArrayListUnmanaged(EventDecl) = .empty, @@ -2469,6 +2507,9 @@ pub const AstArena = struct { self.extra.deinit(gpa); self.strings.deinit(gpa); self.fields.deinit(gpa); + self.import_decls.deinit(gpa); + self.import_path_segs.deinit(gpa); + self.import_items.deinit(gpa); self.component_decls.deinit(gpa); self.resource_decls.deinit(gpa); self.event_decls.deinit(gpa); @@ -3130,6 +3171,15 @@ pub const AstArena = struct { return try self.addItem(gpa, .prefab_decl, idx, span); } + /// `import module_path [import_spec]` (M1.0.7, §5.2). The caller appends the + /// module-path segments to `import_path_segs` and any selective items to + /// `import_items` first, passing the resulting ranges in `decl`. + pub fn addImportDecl(self: *AstArena, gpa: std.mem.Allocator, decl: ImportDecl, span: SourceSpan) !NodeId { + const idx: u32 = @intCast(self.import_decls.items.len); + try self.import_decls.append(gpa, decl); + return try self.addItem(gpa, .import_decl, idx, span); + } + /// `dialogue Name { elements }` (M0.8 E4, `etch-grammar.md` §8.4). The /// caller appends elements to the dialogue slabs beforehand, passing /// the range in `decl`. From ae3d154b66de760bdb5a46fc6c0bc7a244b7dc1d Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 28 Jun 2026 22:20:02 +0200 Subject: [PATCH 06/13] feat(etch): parse import directive, 4 forms + recovery (M1.0.7 E3) --- briefs/M1.0.7-cross-file-import.md | 1 + build.zig | 4 + src/etch/parser.zig | 93 ++++++++++++++++++++++- tests/etch/import_parse_test.zig | 118 +++++++++++++++++++++++++++++ 4 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 tests/etch/import_parse_test.zig diff --git a/briefs/M1.0.7-cross-file-import.md b/briefs/M1.0.7-cross-file-import.md index d203d72..317bf13 100644 --- a/briefs/M1.0.7-cross-file-import.md +++ b/briefs/M1.0.7-cross-file-import.md @@ -185,6 +185,7 @@ Taken with Guy before coding. They are the contract; do not re-litigate. The thr - 2026-06-28 21:40 — E1 seam reconfirmation: confirmed `import` in `non_s3_keywords` (token.zig:296 block), `kw_as` present (token.zig:54/217), no `kw_import`, lexer table-driven (lexer.zig:260-271). KB reconciliations confirmed in the read specs: D-B (`E0101 DuplicateSymbol` + `E0108 ImportCycle`, etch-diagnostics §4/§24.1), D-C (`import` allowed in typed-extension files, etch-grammar §21.2), D-D (`import_item = (IDENT|TYPE_IDENT)`, etch-grammar §5.2). - 2026-06-28 21:40 — E1 graduation: added `kw_import` to `TokenKind` (within kw_let..kw_f64 range) + `s3_keywords`; removed `import` from `non_s3_keywords`. Retargeted the "unknown keyword" lexer test to `const`; added "graduated from reserved" test for `import`. `zig test src/etch/lexer.zig` 17/17 green; `zig build` clean (no exhaustive-switch breakage); `zig fmt --check` green. E1 STOP — awaiting review + GO. - 2026-06-28 21:50 — E1 reviewed + GO (diff 11f6622). E2 AST: read the arena patterns (ComponentDecl `_start`/`_len`, PrefabDecl, the `tag_path_segs`/`prefab_requires` StringId-run precedent). Added `ImportItem {name, alias}` + `ImportDecl {path run, module_alias, items run}` structs, 3 slabs (`import_decls`/`import_path_segs`/`import_items`) + deinit, `addImportDecl` helper. Wired to the pre-existing `ItemKind.import_decl` variant (additive; span carried at the Item slab). D-D accommodated (name/alias are StringIds — IDENT and TYPE_IDENT both fit). `zig build` clean; `zig test src/etch/ast.zig` 6/6; `zig fmt --check` green. E2 STOP — awaiting review + GO. +- 2026-06-28 22:05 — E2 reviewed + GO (diff 7894c2e). E3 parser: `parseImportDecl` (module_path = `.ident`-only run; `as IDENT` module alias; `{ … }` selective spec) + `parseImportItem` (name/alias accept `.ident` AND `.type_ident`, D-D); `kw_import` arm in `parseTopLevel`; `kw_import` added to the `recoverToTopLevel` stop-set + the `else` message. `import` discards annotations (bare `declaration`, `parseTypeAliasDecl` precedent). Created `tests/etch/import_parse_test.zig` (4-forms / IDENT+TYPE_IDENT items / malformed-recovers) + wired the `.etch` target in build.zig (artifact rooted at the test file → no lazy-skip). `zig build` clean; `zig build test` exit 0 (full suite, incl. the 3 new tests; the `failed command` lines are the known macOS cosmetic noise); `zig fmt --check` green. E3 STOP — awaiting review + GO. ## Accepted deviations diff --git a/build.zig b/build.zig index f0276a1..d13dee0 100644 --- a/build.zig +++ b/build.zig @@ -418,6 +418,10 @@ pub fn build(b: *std.Build) void { // M0.9 / E2-B — cross-file scene/prefab validation (E1782 cross-scene, // E1786 cross-file prefab ref, E1791 cross-file prefab base). .{ .path = "tests/etch/crossfile_scene_prefab_test.zig", .etch = true }, + // M1.0.7 / E3 — `import` directive parsing: the four grammar forms + // (whole / selective / aliased / per-item alias), IDENT+TYPE_IDENT items + // (D-D), and malformed-import recovery (resync, no UnsupportedConstructInS3). + .{ .path = "tests/etch/import_parse_test.zig", .etch = true }, // M1.0.0 — interpreter ↔ filtered ECS queries: has / not has / value // field-filters (== and ordered) / and-or-not composition + the // dynamic-archetype (never-matches-then-matches) case + the per-rule diff --git a/src/etch/parser.zig b/src/etch/parser.zig index 1c30fa3..96eb772 100644 --- a/src/etch/parser.zig +++ b/src/etch/parser.zig @@ -626,7 +626,7 @@ pub const Parser = struct { if (self.peek() != .eof) _ = try self.advance(); while (true) { switch (self.peek()) { - .eof, .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_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(), } } @@ -643,6 +643,7 @@ pub const Parser = struct { fn parseTopLevel(self: *Parser, annotations: AnnotationRange) ParseError!void { switch (self.peek()) { + .kw_import => try self.parseImportDecl(annotations), .kw_component => try self.parseComponentDecl(annotations), .kw_resource => try self.parseResourceDecl(annotations), .kw_rule => try self.parseRuleDecl(annotations, false), @@ -686,7 +687,7 @@ pub const Parser = struct { } }, .eof => {}, - else => return self.parseErrFmt(self.peekSpan(), "expected top-level declaration (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 | 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())}), } } @@ -3357,6 +3358,94 @@ pub const Parser = struct { }, .{ .byte_start = kw_span.byte_start, .byte_end = closing.span.byte_end }); } + /// Parse a top-level `import` directive (M1.0.7, `etch-grammar.md` §5.2): + /// `import_decl = "import" module_path [ import_spec ]`. Four forms: + /// import a.b (whole module — implicit alias = last segment) + /// import a.b as m (whole module, explicit alias) + /// import a.b { X, Y } (selective) + /// import a.b { X as Y } (selective with per-item alias) + /// `module_path` is a run of `.ident` segments separated by `.` (≥1) — the + /// path is module-named (snake_case `IDENT`), so segments are `.ident` only; + /// the E0859 path-naming check is out-of-scope. Import items accept BOTH + /// `.ident` and `.type_ident` (D-D). `import` is a bare `declaration` (§5.1, + /// ahead of the `visibility_modifier` branch): it takes no annotations or + /// `private` prefix, so the `annotations` range is discarded (the + /// `parseTypeAliasDecl` precedent). The `kw_import` starter is mirrored in + /// `recoverToTopLevel`'s stop-set. + fn parseImportDecl(self: *Parser, annotations: AnnotationRange) ParseError!void { + _ = annotations; // imports carry no annotations in the v0.6 subset + const kw_span = (try self.advance()).span; // 'import' + + // module_path = IDENT { "." IDENT } (≥1 segment) + const path_start: u32 = @intCast(self.arena.import_path_segs.items.len); + const first_seg = try self.expect(.ident, "expected a module path segment (identifier) after 'import'"); + try self.arena.import_path_segs.append(self.gpa, try self.internSlice(first_seg.span)); + var path_len: u32 = 1; + var end_span = first_seg.span; + while (try self.match(.dot)) { + const seg = try self.expect(.ident, "expected a module path segment (identifier) after '.'"); + try self.arena.import_path_segs.append(self.gpa, try self.internSlice(seg.span)); + path_len += 1; + end_span = seg.span; + } + + // import_spec = "as" IDENT | "{" import_item {"," import_item} [","] "}" + var module_alias: StringId = 0; + const items_start: u32 = @intCast(self.arena.import_items.items.len); + var items_len: u32 = 0; + if (self.peek() == .kw_as) { + _ = try self.advance(); // 'as' + const alias_tok = try self.expect(.ident, "expected a module alias (identifier) after 'as'"); + module_alias = try self.internSlice(alias_tok.span); + end_span = alias_tok.span; + } else if (self.peek() == .lbrace) { + _ = try self.advance(); // '{' + // Grammar requires ≥1 item; a trailing comma before `}` is tolerated. + const first_item = try self.parseImportItem(); + try self.arena.import_items.append(self.gpa, first_item); + items_len += 1; + while (try self.match(.comma)) { + if (self.peek() == .rbrace) break; // trailing comma + const item = try self.parseImportItem(); + try self.arena.import_items.append(self.gpa, item); + items_len += 1; + } + const closing = try self.expect(.rbrace, "expected ',' or '}' to close the import item list"); + end_span = closing.span; + } + + _ = try self.arena.addImportDecl(self.gpa, .{ + .path_start = path_start, + .path_len = path_len, + .module_alias = module_alias, + .items_start = items_start, + .items_len = items_len, + }, .{ .byte_start = kw_span.byte_start, .byte_end = end_span.byte_end }); + } + + /// `import_item = ( IDENT | TYPE_IDENT ) [ "as" ( IDENT | TYPE_IDENT ) ]` + /// (§5.2, reconciled D-D — imported items are mostly `TYPE_IDENT` but `IDENT` + /// is equally legal, so both token kinds are accepted for the name and the + /// optional local alias). The AST stores the interned name; the case + /// distinction is not preserved here. + fn parseImportItem(self: *Parser) ParseError!ast_mod.ImportItem { + const name_tok = if (self.peek() == .ident or self.peek() == .type_ident) + try self.advance() + else + return self.parseErr(self.peekSpan(), "expected an import item name (identifier or type name)"); + const name_id = try self.internSlice(name_tok.span); + var alias: StringId = 0; + if (self.peek() == .kw_as) { + _ = try self.advance(); // 'as' + const alias_tok = if (self.peek() == .ident or self.peek() == .type_ident) + try self.advance() + else + return self.parseErr(self.peekSpan(), "expected a local alias name (identifier or type name) after 'as'"); + alias = try self.internSlice(alias_tok.span); + } + return .{ .name = name_id, .alias = alias }; + } + /// `entity_decl = "entity" STRING_LITERAL "{" [uuid] [parent] /// {component_instance} "}"` (§15 l.1598). Shared by scene + prefab bodies. /// Appends one `SceneEntity`, returns its index. Components append directly diff --git a/tests/etch/import_parse_test.zig b/tests/etch/import_parse_test.zig new file mode 100644 index 0000000..e509c00 --- /dev/null +++ b/tests/etch/import_parse_test.zig @@ -0,0 +1,118 @@ +//! M1.0.7 / E3 — `import` directive parsing. `import` graduated from +//! `non_s3_keywords` to `kw_import` (E1) with an `ImportDecl` AST node (E2); +//! this exercises `parseImportDecl` over the four grammar forms (§5.2): +//! import a.b (whole module) +//! import a.b { X, Y } (selective) +//! import a.b as m (whole module, aliased) +//! import a.b { X as Y } (selective, per-item alias) +//! plus D-D (items accept IDENT and TYPE_IDENT) and recovery (a malformed +//! import resyncs at the next top-level keyword — no UnsupportedConstructInS3). + +const std = @import("std"); +const etch = @import("weld_etch"); + +/// `module_path` segment `i` of `decl` as a string slice. +fn seg(result: anytype, decl: anytype, i: usize) []const u8 { + return result.ast.strings.slice(result.ast.import_path_segs.items[decl.path_start + i]); +} + +test "all four import forms parse" { + const gpa = std.testing.allocator; + + // Form 1: whole module, no alias, no items. + { + var result = try etch.parseSource(gpa, "import a.b"); + defer result.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), result.diagnostics.len); + try std.testing.expectEqual(@as(usize, 1), result.ast.import_decls.items.len); + const d = result.ast.import_decls.items[0]; + try std.testing.expectEqual(@as(u32, 2), d.path_len); + try std.testing.expectEqualStrings("a", seg(result, d, 0)); + try std.testing.expectEqualStrings("b", seg(result, d, 1)); + try std.testing.expectEqual(@as(etch.StringId, 0), d.module_alias); + try std.testing.expectEqual(@as(u32, 0), d.items_len); + } + + // Form 2: selective import of two items. + { + var result = try etch.parseSource(gpa, "import a.b { X, Y }"); + defer result.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), result.diagnostics.len); + const d = result.ast.import_decls.items[0]; + try std.testing.expectEqual(@as(u32, 2), d.path_len); + try std.testing.expectEqual(@as(etch.StringId, 0), d.module_alias); + try std.testing.expectEqual(@as(u32, 2), d.items_len); + const x = result.ast.import_items.items[d.items_start]; + const y = result.ast.import_items.items[d.items_start + 1]; + try std.testing.expectEqualStrings("X", result.ast.strings.slice(x.name)); + try std.testing.expectEqual(@as(etch.StringId, 0), x.alias); + try std.testing.expectEqualStrings("Y", result.ast.strings.slice(y.name)); + try std.testing.expectEqual(@as(etch.StringId, 0), y.alias); + } + + // Form 3: whole module with explicit alias. + { + var result = try etch.parseSource(gpa, "import a.b as m"); + defer result.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), result.diagnostics.len); + const d = result.ast.import_decls.items[0]; + try std.testing.expectEqual(@as(u32, 2), d.path_len); + try std.testing.expect(d.module_alias != 0); + try std.testing.expectEqualStrings("m", result.ast.strings.slice(d.module_alias)); + try std.testing.expectEqual(@as(u32, 0), d.items_len); + } + + // Form 4: selective import with a per-item alias. + { + var result = try etch.parseSource(gpa, "import a.b { X as Y }"); + defer result.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), result.diagnostics.len); + const d = result.ast.import_decls.items[0]; + try std.testing.expectEqual(@as(etch.StringId, 0), d.module_alias); + try std.testing.expectEqual(@as(u32, 1), d.items_len); + const item = result.ast.import_items.items[d.items_start]; + try std.testing.expectEqualStrings("X", result.ast.strings.slice(item.name)); + try std.testing.expect(item.alias != 0); + try std.testing.expectEqualStrings("Y", result.ast.strings.slice(item.alias)); + } +} + +test "import accepts TYPE_IDENT and IDENT items" { + const gpa = std.testing.allocator; + // `Health` is a TYPE_IDENT, `gravity` is an IDENT — both legal items (D-D). + var result = try etch.parseSource(gpa, "import a.b { Health, gravity }"); + defer result.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), result.diagnostics.len); + const d = result.ast.import_decls.items[0]; + try std.testing.expectEqual(@as(u32, 2), d.items_len); + const health = result.ast.import_items.items[d.items_start]; + const gravity = result.ast.import_items.items[d.items_start + 1]; + try std.testing.expectEqualStrings("Health", result.ast.strings.slice(health.name)); + try std.testing.expectEqualStrings("gravity", result.ast.strings.slice(gravity.name)); +} + +test "malformed import recovers" { + const gpa = std.testing.allocator; + // `import 123` is malformed (a module path segment must be an identifier). + // The parser must record a diagnostic, resync at the next top-level keyword, + // and still parse the following `component` — and the diagnostic must NOT be + // the legacy `UnsupportedConstructInS3` (import is no longer a reserved word). + var result = try etch.parseSource(gpa, + \\import 123 + \\component Alpha { a: int = 1 } + ); + defer result.deinit(gpa); + + try std.testing.expect(result.diagnostics.len >= 1); + for (result.diagnostics) |dgn| { + try std.testing.expect(std.mem.indexOf(u8, dgn.primary_message, "UnsupportedConstructInS3") == null); + } + + // The malformed import produced no ImportDecl; `Alpha` still landed. + try std.testing.expectEqual(@as(usize, 0), result.ast.import_decls.items.len); + var saw_alpha = false; + for (result.ast.component_decls.items) |cd| { + if (std.mem.eql(u8, result.ast.strings.slice(cd.name), "Alpha")) saw_alpha = true; + } + try std.testing.expect(saw_alpha); +} From f51a4e54d6f9f856dcdb204a6fb9901b1a31f6fe Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 28 Jun 2026 22:34:10 +0200 Subject: [PATCH 07/13] feat(etch): import module graph, topo order + E0108 cycle (M1.0.7 E4) --- briefs/M1.0.7-cross-file-import.md | 1 + build.zig | 3 + src/etch/diagnostics.zig | 3 + src/etch/root.zig | 152 ++++++++++++++++++++++++++++- tests/etch/import_resolve_test.zig | 53 ++++++++++ 5 files changed, 210 insertions(+), 2 deletions(-) create mode 100644 tests/etch/import_resolve_test.zig diff --git a/briefs/M1.0.7-cross-file-import.md b/briefs/M1.0.7-cross-file-import.md index 317bf13..495c940 100644 --- a/briefs/M1.0.7-cross-file-import.md +++ b/briefs/M1.0.7-cross-file-import.md @@ -186,6 +186,7 @@ Taken with Guy before coding. They are the contract; do not re-litigate. The thr - 2026-06-28 21:40 — E1 graduation: added `kw_import` to `TokenKind` (within kw_let..kw_f64 range) + `s3_keywords`; removed `import` from `non_s3_keywords`. Retargeted the "unknown keyword" lexer test to `const`; added "graduated from reserved" test for `import`. `zig test src/etch/lexer.zig` 17/17 green; `zig build` clean (no exhaustive-switch breakage); `zig fmt --check` green. E1 STOP — awaiting review + GO. - 2026-06-28 21:50 — E1 reviewed + GO (diff 11f6622). E2 AST: read the arena patterns (ComponentDecl `_start`/`_len`, PrefabDecl, the `tag_path_segs`/`prefab_requires` StringId-run precedent). Added `ImportItem {name, alias}` + `ImportDecl {path run, module_alias, items run}` structs, 3 slabs (`import_decls`/`import_path_segs`/`import_items`) + deinit, `addImportDecl` helper. Wired to the pre-existing `ItemKind.import_decl` variant (additive; span carried at the Item slab). D-D accommodated (name/alias are StringIds — IDENT and TYPE_IDENT both fit). `zig build` clean; `zig test src/etch/ast.zig` 6/6; `zig fmt --check` green. E2 STOP — awaiting review + GO. - 2026-06-28 22:05 — E2 reviewed + GO (diff 7894c2e). E3 parser: `parseImportDecl` (module_path = `.ident`-only run; `as IDENT` module alias; `{ … }` selective spec) + `parseImportItem` (name/alias accept `.ident` AND `.type_ident`, D-D); `kw_import` arm in `parseTopLevel`; `kw_import` added to the `recoverToTopLevel` stop-set + the `else` message. `import` discards annotations (bare `declaration`, `parseTypeAliasDecl` precedent). Created `tests/etch/import_parse_test.zig` (4-forms / IDENT+TYPE_IDENT items / malformed-recovers) + wired the `.etch` target in build.zig (artifact rooted at the test file → no lazy-skip). `zig build` clean; `zig build test` exit 0 (full suite, incl. the 3 new tests; the `failed command` lines are the known macOS cosmetic noise); `zig fmt --check` green. E3 STOP — awaiting review + GO. +- 2026-06-28 22:25 — E3 reviewed + GO (diff ae3d154). E4 module graph: added `import_cycle` (E0108) to `DiagnosticCode` + `code()`/`name()` (D-B: E0108, not E0101). `root.zig` `validateProject`: `deriveModulePath` (strip `src/` + `.etch`/typed compound ext, `/`→`.`) + `joinImportPath`; module-path→index map; directed importer→imported graph (edges only between files present in the set — unresolved targets are an E5 concern); iterative DFS → post-order topo order + back-edge → one E0108 (span at the closing import); checkProject now runs in topo order (deps first), input-order fallback on cycle. Verified the resolver tolerates `import_decl` items (`pass1Collect` switch ends `else => {}` forward-compat). Vigilance point: confirmed the crossfile fixtures use bare `*.etch` names (no `src/` prefix) → derivation handles both; typed-ext module label is harmless (typed files are never import targets). Created `tests/etch/import_resolve_test.zig` (cycle → E0108; linear → no E0108) + wired build.zig. `zig build` clean; `zig build test` exit 0; `zig fmt --check` green. E4 STOP — awaiting review + GO. ## Accepted deviations diff --git a/build.zig b/build.zig index d13dee0..2973d91 100644 --- a/build.zig +++ b/build.zig @@ -422,6 +422,9 @@ pub fn build(b: *std.Build) void { // (whole / selective / aliased / per-item alias), IDENT+TYPE_IDENT items // (D-D), and malformed-import recovery (resync, no UnsupportedConstructInS3). .{ .path = "tests/etch/import_parse_test.zig", .etch = true }, + // M1.0.7 / E4 — module graph + topological order + cycle detection + // (E0108). E5/E6 extend this file with selective-import resolution. + .{ .path = "tests/etch/import_resolve_test.zig", .etch = true }, // M1.0.0 — interpreter ↔ filtered ECS queries: has / not has / value // field-filters (== and ordered) / and-or-not composition + the // dynamic-archetype (never-matches-then-matches) case + the per-rule diff --git a/src/etch/diagnostics.zig b/src/etch/diagnostics.zig index 53c69e3..b7fc9b4 100644 --- a/src/etch/diagnostics.zig +++ b/src/etch/diagnostics.zig @@ -33,6 +33,7 @@ pub const DiagnosticCode = enum { duplicate_symbol, // S3 — E0101 DuplicateSymbol undefined_symbol, // S3 — E0102 UndefinedSymbol enum_variant_not_found, // M0.8 — E0105 EnumVariantNotFound + import_cycle, // M1.0.7 E4 — E0108 ImportCycle (D-B: NOT E0101; E0101 is DuplicateSymbol) // ── Type errors (E0200-E0299) ── type_mismatch, // S3 — E0200 TypeMismatch @@ -357,6 +358,7 @@ pub const DiagnosticCode = enum { .parse_error => "E0001", .duplicate_symbol => "E0101", .undefined_symbol => "E0102", + .import_cycle => "E0108", .enum_variant_not_found => "E0105", .type_mismatch => "E0200", .arg_count_mismatch => "E0203", @@ -537,6 +539,7 @@ pub const DiagnosticCode = enum { .parse_error => "ParseError", .duplicate_symbol => "DuplicateSymbol", .undefined_symbol => "UndefinedSymbol", + .import_cycle => "ImportCycle", .enum_variant_not_found => "EnumVariantNotFound", .type_mismatch => "TypeMismatch", .arg_count_mismatch => "ArgCountMismatch", diff --git a/src/etch/root.zig b/src/etch/root.zig index b55a0a4..77884fe 100644 --- a/src/etch/root.zig +++ b/src/etch/root.zig @@ -186,6 +186,47 @@ pub const ProjectFile = struct { source: []const u8, }; +/// Module path of a project file from its `ProjectFile.name` (path under `src/`, +/// `etch-reference-part1.md` §1.1): strip an optional leading `src/`, strip the +/// file extension (a typed compound `.scene.etch`/`.prefab.etch`/`.layer.etch`/ +/// `.manifest.etch`/`.d.etch` if present, else plain `.etch`), and map `/`→`.`. +/// The returned slice is `gpa`-owned. Typed-extension files (scene/prefab/…) take +/// their basename as the module label; they are never import *targets* (they +/// declare no top-level types — only one scene/prefab + imports), so the label +/// only identifies them as nodes in the dependency graph (M1.0.7 E4). +fn deriveModulePath(gpa: std.mem.Allocator, name: []const u8) ![]u8 { + var s = name; + if (std.mem.startsWith(u8, s, "src/")) s = s["src/".len..]; + const typed_exts = [_][]const u8{ ".d.etch", ".scene.etch", ".prefab.etch", ".layer.etch", ".manifest.etch" }; + var stripped = false; + for (typed_exts) |ext| { + if (std.mem.endsWith(u8, s, ext)) { + s = s[0 .. s.len - ext.len]; + stripped = true; + break; + } + } + if (!stripped and std.mem.endsWith(u8, s, ".etch")) s = s[0 .. s.len - ".etch".len]; + const out = try gpa.dupe(u8, s); + for (out) |*c| { + if (c.* == '/') c.* = '.'; + } + return out; +} + +/// The dotted module path an `ImportDecl` references (`import a.b.c` → `"a.b.c"`), +/// joined from its `import_path_segs` run. `gpa`-owned (M1.0.7 E4). +fn joinImportPath(gpa: std.mem.Allocator, a: *const Ast, decl: ast.ImportDecl) ![]u8 { + var buf: std.ArrayListUnmanaged(u8) = .empty; + errdefer buf.deinit(gpa); + var i: u32 = 0; + while (i < decl.path_len) : (i += 1) { + if (i != 0) try buf.append(gpa, '.'); + try buf.appendSlice(gpa, a.strings.slice(a.import_path_segs.items[decl.path_start + i])); + } + return try buf.toOwnedSlice(gpa); +} + /// Cross-file scene/prefab validation (M0.9 E2-B). Parses every project file, /// builds the byte-keyed global prefab-name index and a shared cross-scene /// UUID tracker, then type-checks each file with that project context so the @@ -245,9 +286,116 @@ pub fn validateProject( var uuids: std.StringHashMapUnmanaged(void) = .empty; defer uuids.deinit(gpa); + // ── M1.0.7 E4 — module graph + topological order + cycle detection ── + // Derive each file's module path and build module-path → index map. + const n = asts.items.len; + var module_paths: std.ArrayListUnmanaged([]u8) = .empty; + defer { + for (module_paths.items) |p| gpa.free(p); + module_paths.deinit(gpa); + } + try module_paths.ensureTotalCapacity(gpa, n); + var module_index: std.StringHashMapUnmanaged(usize) = .empty; + defer module_index.deinit(gpa); + for (files, 0..) |f, idx| { + const mp = try deriveModulePath(gpa, f.name); + module_paths.appendAssumeCapacity(mp); + // A duplicate module path (an out-of-scope edge case) maps to the last + // file; E4 only needs a consistent node identity for the graph. + try module_index.put(gpa, mp, idx); + } + + // Build the directed import-dependency graph: edge importer → imported, for + // each import whose target module resolves to a file in the set. Targets that + // resolve to no file are an E5 concern (E0103/E0104), not a cycle edge. + const Edge = struct { to: usize, span: SourceSpan }; + var adj: std.ArrayListUnmanaged(std.ArrayListUnmanaged(Edge)) = .empty; + defer { + for (adj.items) |*lst| lst.deinit(gpa); + adj.deinit(gpa); + } + try adj.ensureTotalCapacity(gpa, n); + for (0..n) |_| adj.appendAssumeCapacity(.empty); + for (asts.items, 0..) |*a, u| { + const kinds = a.items.items(.kind); + const datas = a.items.items(.data); + const spans = a.items.items(.span); + var i: usize = 0; + while (i < a.items.len) : (i += 1) { + if (kinds[i] != .import_decl) continue; + const decl = a.import_decls.items[datas[i]]; + const target_path = try joinImportPath(gpa, a, decl); + defer gpa.free(target_path); + if (module_index.get(target_path)) |v| { + try adj.items[u].append(gpa, .{ .to = v, .span = spans[i] }); + } + } + } + + // Iterative DFS: post-order yields a dependencies-first topological order; a + // back-edge (to a gray/on-stack node) closes a cycle → E0108 pointing at the + // import that closes the loop. White = 0, gray = 1, black = 2. + const colors = try gpa.alloc(u8, n); + defer gpa.free(colors); + @memset(colors, 0); + var order: std.ArrayListUnmanaged(usize) = .empty; + defer order.deinit(gpa); + try order.ensureTotalCapacity(gpa, n); + const Frame = struct { node: usize, ei: usize }; + var stack: std.ArrayListUnmanaged(Frame) = .empty; + defer stack.deinit(gpa); + var cycle_found = false; + for (0..n) |start| { + if (colors[start] != 0) continue; + colors[start] = 1; + stack.clearRetainingCapacity(); + try stack.append(gpa, .{ .node = start, .ei = 0 }); + while (stack.items.len > 0) { + const frame = &stack.items[stack.items.len - 1]; + const edges = adj.items[frame.node].items; + if (frame.ei < edges.len) { + const edge = edges[frame.ei]; + frame.ei += 1; + switch (colors[edge.to]) { + 0 => { + colors[edge.to] = 1; + try stack.append(gpa, .{ .node = edge.to, .ei = 0 }); + }, + 1 => { + // Back-edge: `from` imports `to`, already on the stack. + cycle_found = true; + const from_node = frame.node; + const msg = try std.fmt.allocPrint( + gpa, + "import cycle detected: module '{s}' imports '{s}', which closes a cycle back to '{s}'", + .{ module_paths.items[from_node], module_paths.items[edge.to], module_paths.items[edge.to] }, + ); + errdefer gpa.free(msg); + try diags_out.append(gpa, .{ + .code = .import_cycle, + .severity = .error_, + .primary_span = edge.span, + .primary_message = msg, + }); + }, + else => {}, // black: already finished, no cycle + } + } else { + colors[frame.node] = 2; + order.appendAssumeCapacity(frame.node); + _ = stack.pop(); + } + } + } + + // Check each file with the project context. Acyclic → topological order so a + // module's dependencies are checked first (M1.0.7 E6 exports collection); + // on a cycle, fall back to input order (the graph has no valid linearization). const ctx: TypeChecker.ProjectContext = .{ .prefabs = &prefabs, .uuids = &uuids }; - for (asts.items) |*a| { - try TypeChecker.checkProject(gpa, a, diags_out, &ctx); + var k: usize = 0; + while (k < n) : (k += 1) { + const idx = if (cycle_found) k else order.items[k]; + try TypeChecker.checkProject(gpa, &asts.items[idx], diags_out, &ctx); } } diff --git a/tests/etch/import_resolve_test.zig b/tests/etch/import_resolve_test.zig new file mode 100644 index 0000000..b93b972 --- /dev/null +++ b/tests/etch/import_resolve_test.zig @@ -0,0 +1,53 @@ +//! M1.0.7 — cross-file `import` resolution under `validateProject`. +//! +//! E4 scope (this file, initial): the module dependency graph + topological +//! order + cycle detection (`E0108 ImportCycle`). E5/E6 extend it with the +//! selective-import resolution tests (cross-file type/const, `E0104`). +//! +//! D-B reminder: the cycle code is `E0108`, NOT `E0101` (which is +//! `DuplicateSymbol`, shipped since M0.x). + +const std = @import("std"); +const etch = @import("weld_etch"); +const DiagnosticCode = etch.diagnostics.DiagnosticCode; + +fn countCode(diags: []const etch.Diagnostic, code: DiagnosticCode) usize { + var n: usize = 0; + for (diags) |d| { + if (d.code == code) n += 1; + } + return n; +} + +fn deinitDiags(gpa: std.mem.Allocator, diags: *std.ArrayListUnmanaged(etch.Diagnostic)) void { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); +} + +test "import cycle errors" { + const gpa = std.testing.allocator; + // module `a` imports `b`, module `b` imports `a` → a 2-cycle closes on the + // back-edge → exactly one E0108. + const files = [_]etch.ProjectFile{ + .{ .name = "a.etch", .source = "import b" }, + .{ .name = "b.etch", .source = "import a" }, + }; + 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_cycle)); +} + +test "linear import is not a cycle" { + const gpa = std.testing.allocator; + // `a` imports `b`, `b` imports nothing → acyclic, no E0108 (guards the DFS + // against over-reporting a forward edge as a back-edge). + const files = [_]etch.ProjectFile{ + .{ .name = "a.etch", .source = "import b" }, + .{ .name = "b.etch", .source = "component Marker { id: int = 0 }" }, + }; + 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, .import_cycle)); +} From a104deaafadf30452d6c78065ce3cbf7599b5295 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Sun, 28 Jun 2026 22:56:01 +0200 Subject: [PATCH 08/13] feat(etch): per-module exports index + import binding (M1.0.7 E5) --- briefs/M1.0.7-cross-file-import.md | 1 + src/etch/diagnostics.zig | 9 +++ src/etch/root.zig | 60 +++++++++++++++- src/etch/types.zig | 111 +++++++++++++++++++++++++++++ tests/etch/import_resolve_test.zig | 41 +++++++++++ 5 files changed, 221 insertions(+), 1 deletion(-) diff --git a/briefs/M1.0.7-cross-file-import.md b/briefs/M1.0.7-cross-file-import.md index 495c940..65c4654 100644 --- a/briefs/M1.0.7-cross-file-import.md +++ b/briefs/M1.0.7-cross-file-import.md @@ -187,6 +187,7 @@ Taken with Guy before coding. They are the contract; do not re-litigate. The thr - 2026-06-28 21:50 — E1 reviewed + GO (diff 11f6622). E2 AST: read the arena patterns (ComponentDecl `_start`/`_len`, PrefabDecl, the `tag_path_segs`/`prefab_requires` StringId-run precedent). Added `ImportItem {name, alias}` + `ImportDecl {path run, module_alias, items run}` structs, 3 slabs (`import_decls`/`import_path_segs`/`import_items`) + deinit, `addImportDecl` helper. Wired to the pre-existing `ItemKind.import_decl` variant (additive; span carried at the Item slab). D-D accommodated (name/alias are StringIds — IDENT and TYPE_IDENT both fit). `zig build` clean; `zig test src/etch/ast.zig` 6/6; `zig fmt --check` green. E2 STOP — awaiting review + GO. - 2026-06-28 22:05 — E2 reviewed + GO (diff 7894c2e). E3 parser: `parseImportDecl` (module_path = `.ident`-only run; `as IDENT` module alias; `{ … }` selective spec) + `parseImportItem` (name/alias accept `.ident` AND `.type_ident`, D-D); `kw_import` arm in `parseTopLevel`; `kw_import` added to the `recoverToTopLevel` stop-set + the `else` message. `import` discards annotations (bare `declaration`, `parseTypeAliasDecl` precedent). Created `tests/etch/import_parse_test.zig` (4-forms / IDENT+TYPE_IDENT items / malformed-recovers) + wired the `.etch` target in build.zig (artifact rooted at the test file → no lazy-skip). `zig build` clean; `zig build test` exit 0 (full suite, incl. the 3 new tests; the `failed command` lines are the known macOS cosmetic noise); `zig fmt --check` green. E3 STOP — awaiting review + GO. - 2026-06-28 22:25 — E3 reviewed + GO (diff ae3d154). E4 module graph: added `import_cycle` (E0108) to `DiagnosticCode` + `code()`/`name()` (D-B: E0108, not E0101). `root.zig` `validateProject`: `deriveModulePath` (strip `src/` + `.etch`/typed compound ext, `/`→`.`) + `joinImportPath`; module-path→index map; directed importer→imported graph (edges only between files present in the set — unresolved targets are an E5 concern); iterative DFS → post-order topo order + back-edge → one E0108 (span at the closing import); checkProject now runs in topo order (deps first), input-order fallback on cycle. Verified the resolver tolerates `import_decl` items (`pass1Collect` switch ends `else => {}` forward-compat). Vigilance point: confirmed the crossfile fixtures use bare `*.etch` names (no `src/` prefix) → derivation handles both; typed-ext module label is harmless (typed files are never import targets). Created `tests/etch/import_resolve_test.zig` (cycle → E0108; linear → no E0108) + wired build.zig. `zig build` clean; `zig build test` exit 0; `zig fmt --check` green. E4 STOP — awaiting review + GO. +- 2026-06-28 22:50 — E4 reviewed + GO (diff f51a4e5). E5 exports + binding: added `not_a_module` (E0103), `unknown_export` (E0104), `import_private_item` (E0107) to `DiagnosticCode` + switches. `types.zig`: PER-MODULE exports (`ExportEntry {kind, visibility, arena_index, item_id}` + `ExportTable`; NOT a flat bare-name index — `import a.b {X}` resolves X in module a.b's exports specifically); `ProjectContext` gains `module_index`/`exports[]`/`arenas[]`; new per-file `imported_symbols` + `imported_aliases` maps + deinit; `bindImports` pass (after pass1) emits E0103/E0104, records selective items under their local name, records whole-module/`as m` alias bindings (D-F), E0107 wired-but-dormant via the `.public`-only visibility flag (D-G). `root.zig`: `buildExports` (component/resource/struct/enum/trait/event/fn/type_alias, all-public) per file + threaded into ctx. E5/E6 boundary respected — bindImports records bindings only; TYPE_IDENT application + cross-arena component check are E6. Extended `import_resolve_test.zig` (E0104 / valid-selective-no-diag / E0103). `zig build` clean; `zig build test` exit 0; `zig fmt --check` green. E5 STOP — awaiting review + GO. ## Accepted deviations diff --git a/src/etch/diagnostics.zig b/src/etch/diagnostics.zig index b7fc9b4..dbe341c 100644 --- a/src/etch/diagnostics.zig +++ b/src/etch/diagnostics.zig @@ -32,7 +32,10 @@ pub const DiagnosticCode = enum { // ── Resolver — symbols / paths (E0100-E0199) ── duplicate_symbol, // S3 — E0101 DuplicateSymbol undefined_symbol, // S3 — E0102 UndefinedSymbol + 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_cycle, // M1.0.7 E4 — E0108 ImportCycle (D-B: NOT E0101; E0101 is DuplicateSymbol) // ── Type errors (E0200-E0299) ── @@ -358,6 +361,9 @@ pub const DiagnosticCode = enum { .parse_error => "E0001", .duplicate_symbol => "E0101", .undefined_symbol => "E0102", + .not_a_module => "E0103", + .unknown_export => "E0104", + .import_private_item => "E0107", .import_cycle => "E0108", .enum_variant_not_found => "E0105", .type_mismatch => "E0200", @@ -539,6 +545,9 @@ pub const DiagnosticCode = enum { .parse_error => "ParseError", .duplicate_symbol => "DuplicateSymbol", .undefined_symbol => "UndefinedSymbol", + .not_a_module => "NotAModule", + .unknown_export => "UnknownExport", + .import_private_item => "ImportPrivateItem", .import_cycle => "ImportCycle", .enum_variant_not_found => "EnumVariantNotFound", .type_mismatch => "TypeMismatch", diff --git a/src/etch/root.zig b/src/etch/root.zig index 77884fe..8b45b0c 100644 --- a/src/etch/root.zig +++ b/src/etch/root.zig @@ -227,6 +227,41 @@ fn joinImportPath(gpa: std.mem.Allocator, a: *const Ast, decl: ast.ImportDecl) ! return try buf.toOwnedSlice(gpa); } +/// Build module `a`'s public exports table (M1.0.7 E5): every top-level +/// symbol-bearing declaration (component / resource / struct / enum / trait / +/// event / fn / type-alias) keyed by its interned name's bytes → +/// `{ kind, visibility, arena_index, item_id }`. All-public until `private` +/// graduates (D-G). Keys reference `a`'s string pool, kept alive by the caller. +fn buildExports(gpa: std.mem.Allocator, a: *const Ast, arena_index: usize, table: *TypeChecker.ExportTable) !void { + const kinds = a.items.items(.kind); + const datas = a.items.items(.data); + var i: usize = 0; + while (i < a.items.len) : (i += 1) { + const item_id: NodeId = .{ .category = .item, .index = @intCast(i) }; + const nk: ?struct { name: StringId, kind: types.SymbolKind } = switch (kinds[i]) { + .component_decl => .{ .name = a.component_decls.items[datas[i]].name, .kind = .component }, + .resource_decl => .{ .name = a.resource_decls.items[datas[i]].name, .kind = .resource }, + .struct_decl => .{ .name = a.struct_decls.items[datas[i]].name, .kind = .struct_ }, + .enum_decl => .{ .name = a.enum_decls.items[datas[i]].name, .kind = .enum_ }, + .trait_decl => .{ .name = a.trait_decls.items[datas[i]].name, .kind = .trait_ }, + .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 }, + 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. + try table.put(gpa, a.strings.slice(e.name), .{ + .kind = e.kind, + .visibility = .public, + .arena_index = arena_index, + .item_id = item_id, + }); + } + } +} + /// Cross-file scene/prefab validation (M0.9 E2-B). Parses every project file, /// builds the byte-keyed global prefab-name index and a shared cross-scene /// UUID tracker, then type-checks each file with that project context so the @@ -388,10 +423,33 @@ pub fn validateProject( } } + // Per-module exports tables (M1.0.7 E5): one byte-keyed table per file, so + // `import a.b { X }` resolves X in module `a.b`'s exports SPECIFICALLY — two + // modules exporting the same name never collide (unlike the flat global + // `prefabs` index, whose names are project-global by design). + var exports: std.ArrayListUnmanaged(TypeChecker.ExportTable) = .empty; + defer { + for (exports.items) |*t| t.deinit(gpa); + exports.deinit(gpa); + } + try exports.ensureTotalCapacity(gpa, n); + for (asts.items, 0..) |*a, idx| { + var table: TypeChecker.ExportTable = .empty; + errdefer table.deinit(gpa); + try buildExports(gpa, a, idx, &table); + exports.appendAssumeCapacity(table); + } + // Check each file with the project context. Acyclic → topological order so a // module's dependencies are checked first (M1.0.7 E6 exports collection); // on a cycle, fall back to input order (the graph has no valid linearization). - const ctx: TypeChecker.ProjectContext = .{ .prefabs = &prefabs, .uuids = &uuids }; + const ctx: TypeChecker.ProjectContext = .{ + .prefabs = &prefabs, + .uuids = &uuids, + .module_index = &module_index, + .exports = exports.items, + .arenas = asts.items, + }; var k: usize = 0; while (k < n) : (k += 1) { const idx = if (cycle_found) k else order.items[k]; diff --git a/src/etch/types.zig b/src/etch/types.zig index e912cf5..be16c4b 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -309,6 +309,16 @@ pub const TypeChecker = struct { /// checks resolve against the byte-keyed PROJECT indexes instead, so a /// prefab or UUID defined in another project file is in scope. project: ?*const ProjectContext = null, + /// Symbols brought into this file's scope by a selective `import a.b { X }` + /// (M1.0.7 E5), byte-keyed under their LOCAL name's `StringId` in THIS arena + /// (the `as Y` alias if present, else the imported name). Built by + /// `bindImports` after pass 1; consulted by E6's `TYPE_IDENT` resolution. + /// Empty in single-file mode. + imported_symbols: std.AutoHashMapUnmanaged(StringId, ExportEntry) = .empty, + /// Module aliases from `import a.b as m` / bare `import a.b` (M1.0.7 E5, D-F): + /// local alias `StringId` → target file index. Qualified `m.Type` resolution + /// is deferred; the binding is recorded so the later walk is purely additive. + imported_aliases: std.AutoHashMapUnmanaged(StringId, usize) = .empty, /// Byte-keyed cross-file indexes for project-level scene/prefab validation /// (M0.9 E2-B). StringIds are per-arena, so cross-file resolution keys on @@ -318,9 +328,38 @@ 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. + pub const Visibility = enum { public, private }; + + /// One exported top-level symbol of a module (M1.0.7 E5). `arena_index` is + /// the defining file's index in the project (its slot in `exports`/`arenas`); + /// `item_id` is its `Item` NodeId in that arena. The cross-arena field check + /// (E6) fetches the decl via `project.arenas[arena_index]` + `item_id`. + pub const ExportEntry = struct { + kind: SymbolKind, + visibility: Visibility, + arena_index: usize, + item_id: NodeId, + }; + + /// Per-module exports table, byte-keyed on the interned export name (StringIds + /// are per-arena → cross-file keys on bytes, the M0.9 pattern). One table per + /// project file, indexed by file index in `ProjectContext.exports`. + pub const ExportTable = std.StringHashMapUnmanaged(ExportEntry); + pub const ProjectContext = struct { prefabs: *const std.StringHashMapUnmanaged(void), uuids: *std.StringHashMapUnmanaged(void), + // ── M1.0.7 E5 — cross-file import resolution ── + /// Module-path bytes → file index (built by `root.validateProject`). + module_index: *const std.StringHashMapUnmanaged(usize), + /// Per-file exports tables, indexed by file index. + exports: []const ExportTable, + /// Per-file arenas, indexed by file index (= the project's parsed ASTs). + /// The defining arena for E6's cross-arena component-decl fetch. + arenas: []AstArena, }; /// One `impl Trait for Type [when …]` (M0.8 E2 block 3 tranche C). @@ -341,6 +380,8 @@ pub const TypeChecker = struct { self.methods.deinit(self.gpa); self.trait_impls.deinit(self.gpa); self.generic_scope.deinit(self.gpa); + self.imported_symbols.deinit(self.gpa); + self.imported_aliases.deinit(self.gpa); if (self.tag_table) |*t| t.deinit(self.gpa); } @@ -379,6 +420,7 @@ pub const TypeChecker = struct { }; defer tc.deinit(); try tc.pass1Collect(); + try tc.bindImports(); try tc.validateTypeAliases(); try tc.validateImpls(); try tc.validateDataDecls(); @@ -2786,6 +2828,75 @@ pub const TypeChecker = struct { } } + /// Bind this file's `import` directives against the project exports index + /// (M1.0.7 E5). For each `ImportDecl`: + /// - 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). + /// - 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). + /// No-op in single-file mode (`project == null`). This pass only records the + /// bindings + emits the import diagnostics; APPLYING the imports to + /// `TYPE_IDENT` resolution + the cross-arena component check is E6. + fn bindImports(self: *TypeChecker) !void { + const project = self.project orelse return; + const kinds = self.arena.items.items(.kind); + const datas = self.arena.items.items(.data); + const spans = self.arena.items.items(.span); + var i: usize = 0; + while (i < self.arena.items.len) : (i += 1) { + if (kinds[i] != .import_decl) continue; + const decl = self.arena.import_decls.items[datas[i]]; + const span = spans[i]; + + // Join the module-path segments ("a.b.c") and resolve to a file. + var path_buf: std.ArrayListUnmanaged(u8) = .empty; + defer path_buf.deinit(self.gpa); + var s: u32 = 0; + while (s < decl.path_len) : (s += 1) { + if (s != 0) try path_buf.append(self.gpa, '.'); + try path_buf.appendSlice(self.gpa, self.arena.strings.slice(self.arena.import_path_segs.items[decl.path_start + s])); + } + const target_idx = project.module_index.get(path_buf.items) orelse { + try self.emit(.not_a_module, .error_, span, "import path '{s}' does not name a module in the project", .{path_buf.items}); + continue; + }; + + if (decl.items_len == 0) { + // Whole-module form (1 or 3): record the alias → target binding. + // Explicit `as m` alias, else the implicit last-segment alias. + const alias_id = if (decl.module_alias != 0) + decl.module_alias + else + self.arena.import_path_segs.items[decl.path_start + decl.path_len - 1]; + try self.imported_aliases.put(self.gpa, alias_id, target_idx); + continue; + } + + // Selective form (2 or 4): bind each item against the target exports. + const exports = &project.exports[target_idx]; + var j: u32 = 0; + while (j < decl.items_len) : (j += 1) { + const item = self.arena.import_items.items[decl.items_start + j]; + const item_name = self.arena.strings.slice(item.name); + const entry = exports.get(item_name) orelse { + try self.emit(.unknown_export, .error_, span, "'{s}' is not exported by module '{s}'", .{ item_name, path_buf.items }); + continue; + }; + if (entry.visibility == .private) { + // Dormant until `private` graduates (M1.0.8, D-G). + try self.emit(.import_private_item, .error_, span, "'{s}' is private to module '{s}'", .{ item_name, path_buf.items }); + continue; + } + const local = if (item.alias != 0) item.alias else item.name; + try self.imported_symbols.put(self.gpa, local, entry); + } + } + } + fn registerSymbol(self: *TypeChecker, kind: SymbolKind, name: StringId, item_id: NodeId, span: SourceSpan) !void { const gop = try self.symbols.getOrPut(self.gpa, name); if (gop.found_existing) { diff --git a/tests/etch/import_resolve_test.zig b/tests/etch/import_resolve_test.zig index b93b972..ffb2454 100644 --- a/tests/etch/import_resolve_test.zig +++ b/tests/etch/import_resolve_test.zig @@ -51,3 +51,44 @@ test "linear import is not a cycle" { try etch.validateProject(gpa, &files, &diags); try std.testing.expectEqual(@as(usize, 0), countCode(diags.items, .import_cycle)); } + +test "unknown export errors (E0104)" { + const gpa = std.testing.allocator; + // `lib` exports `Health`; `main` selectively imports `Nope`, which `lib` does + // not export → exactly one E0104 (and Health is unaffected). + const files = [_]etch.ProjectFile{ + .{ .name = "lib.etch", .source = "component Health { current: float = 100.0 }" }, + .{ .name = "main.etch", .source = "import lib { Nope }" }, + }; + 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)); +} + +test "valid selective import emits no import diagnostic (E5 binding)" { + const gpa = std.testing.allocator; + // `main` imports an item `lib` actually exports → the binding succeeds with no + // E0103/E0104 (TYPE_IDENT application + the prefab unblock are E6). + const files = [_]etch.ProjectFile{ + .{ .name = "lib.etch", .source = "component Health { current: float = 100.0 }" }, + .{ .name = "main.etch", .source = "import lib { Health }" }, + }; + 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, .not_a_module)); +} + +test "import of a missing module errors (E0103)" { + const gpa = std.testing.allocator; + // `main` imports `ghost`, which names no file in the set → exactly one E0103. + const files = [_]etch.ProjectFile{ + .{ .name = "main.etch", .source = "import ghost" }, + }; + 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, .not_a_module)); +} From 6d4f34446404fd3b567fe2f11b5552ee71aa7a68 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 29 Jun 2026 00:58:28 +0200 Subject: [PATCH 09/13] feat(etch): cross-file import resolution + E1793 unblock (M1.0.7 E6) --- briefs/M1.0.7-cross-file-import.md | 5 +- build.zig | 7 +- src/etch/types.zig | 102 ++++++++++++++++++-- tests/etch/crossfile_prefab_import_test.zig | 75 ++++++++++++++ tests/etch/import_resolve_test.zig | 18 ++++ 5 files changed, 195 insertions(+), 12 deletions(-) create mode 100644 tests/etch/crossfile_prefab_import_test.zig diff --git a/briefs/M1.0.7-cross-file-import.md b/briefs/M1.0.7-cross-file-import.md index 65c4654..702b56e 100644 --- a/briefs/M1.0.7-cross-file-import.md +++ b/briefs/M1.0.7-cross-file-import.md @@ -120,7 +120,7 @@ Taken with Guy before coding. They are the contract; do not re-litigate. The thr ### Tests - `tests/etch/import_parse_test.zig` — `test "all four import forms parse"` — `import a.b`, `import a.b { X, Y }`, `import a.b as m`, `import a.b { X as Y }` each build an `ImportDecl` with the expected path / alias / items. `test "import accepts TYPE_IDENT and IDENT items"` — `import a.b { Health, gravity }` parses both. `test "malformed import recovers"` — a broken `import` yields a diagnostic and the next top-level decl still parses (no `UnsupportedConstructInS3`). -- `tests/etch/import_resolve_test.zig` — `test "selective import resolves a cross-file type"` — file B `import a { Health }` references `Health` declared in file A; resolves with no diagnostic under `validateProject`. `test "selective import resolves a cross-file const"`. `test "unknown export errors"` — `import a { Nope }` → `E0104`. `test "import cycle errors"` — A imports B, B imports A → `E0108` at the closing module. +- `tests/etch/import_resolve_test.zig` — `test "selective import resolves a cross-file type"` — file B `import a { Health }` references `Health` declared in file A; resolves with no diagnostic under `validateProject`. (Cross-file `const` import deferred to M1.0.8 — `const` is not parseable until it graduates; cross-file resolution is covered by this imported-component type test + the prefab unblock. Accepted deviation, Claude.ai round-trip.) `test "unknown export errors"` — `import a { Nope }` → `E0104`. `test "import cycle errors"` — A imports B, B imports A → `E0108` at the closing module. - `tests/etch/crossfile_prefab_import_test.zig` — `test "prefab importing its components validates clean"` — a `.prefab.etch` that `import`s `Transform`/`Health` from another file validates under `validateProject` with **no E1793**. `test "prefab with an undeclared component still errors"` — referencing a component declared nowhere → `E1793`. ### Benchmarks @@ -187,13 +187,14 @@ Taken with Guy before coding. They are the contract; do not re-litigate. The thr - 2026-06-28 21:50 — E1 reviewed + GO (diff 11f6622). E2 AST: read the arena patterns (ComponentDecl `_start`/`_len`, PrefabDecl, the `tag_path_segs`/`prefab_requires` StringId-run precedent). Added `ImportItem {name, alias}` + `ImportDecl {path run, module_alias, items run}` structs, 3 slabs (`import_decls`/`import_path_segs`/`import_items`) + deinit, `addImportDecl` helper. Wired to the pre-existing `ItemKind.import_decl` variant (additive; span carried at the Item slab). D-D accommodated (name/alias are StringIds — IDENT and TYPE_IDENT both fit). `zig build` clean; `zig test src/etch/ast.zig` 6/6; `zig fmt --check` green. E2 STOP — awaiting review + GO. - 2026-06-28 22:05 — E2 reviewed + GO (diff 7894c2e). E3 parser: `parseImportDecl` (module_path = `.ident`-only run; `as IDENT` module alias; `{ … }` selective spec) + `parseImportItem` (name/alias accept `.ident` AND `.type_ident`, D-D); `kw_import` arm in `parseTopLevel`; `kw_import` added to the `recoverToTopLevel` stop-set + the `else` message. `import` discards annotations (bare `declaration`, `parseTypeAliasDecl` precedent). Created `tests/etch/import_parse_test.zig` (4-forms / IDENT+TYPE_IDENT items / malformed-recovers) + wired the `.etch` target in build.zig (artifact rooted at the test file → no lazy-skip). `zig build` clean; `zig build test` exit 0 (full suite, incl. the 3 new tests; the `failed command` lines are the known macOS cosmetic noise); `zig fmt --check` green. E3 STOP — awaiting review + GO. - 2026-06-28 22:25 — E3 reviewed + GO (diff ae3d154). E4 module graph: added `import_cycle` (E0108) to `DiagnosticCode` + `code()`/`name()` (D-B: E0108, not E0101). `root.zig` `validateProject`: `deriveModulePath` (strip `src/` + `.etch`/typed compound ext, `/`→`.`) + `joinImportPath`; module-path→index map; directed importer→imported graph (edges only between files present in the set — unresolved targets are an E5 concern); iterative DFS → post-order topo order + back-edge → one E0108 (span at the closing import); checkProject now runs in topo order (deps first), input-order fallback on cycle. Verified the resolver tolerates `import_decl` items (`pass1Collect` switch ends `else => {}` forward-compat). Vigilance point: confirmed the crossfile fixtures use bare `*.etch` names (no `src/` prefix) → derivation handles both; typed-ext module label is harmless (typed files are never import targets). Created `tests/etch/import_resolve_test.zig` (cycle → E0108; linear → no E0108) + wired build.zig. `zig build` clean; `zig build test` exit 0; `zig fmt --check` green. E4 STOP — awaiting review + GO. +- 2026-06-29 09:30 — E5 reviewed + GO (diff a104dea). E6 apply + cross-arena (final slice): `namedTypeToResolved` + `validateTypeAliases` fall back to `imported_symbols` (imported `TYPE_IDENT` resolves in type positions — `type HA = Health` no longer E0102). `checkComponentInstance` (D-E): local component → existing path; imported component → fetch decl from `project.arenas[entry.arena_index]` + `checkInstanceFieldForeign` (cross-arena, field names by BYTES); else E1793. `checkInstanceFieldForeign` does E1794 (field-name byte-keyed) full + E1795 for BUILTIN foreign field types only (`foreignBuiltinFieldType`, no symbol-table dependency); named foreign field types are the documented residual (would balloon — needs the foreign module's resolved symbols). E1793 unblock works. Const-import test deferred to M1.0.8 (Claude.ai GO E5→E6 decision; FROZEN Acceptance edit + Accepted-deviations entry). Extended `import_resolve_test.zig` (cross-file type resolves, no E0102); created `tests/etch/crossfile_prefab_import_test.zig` (prefab imports its components → clean; undeclared → E1793) + wired build.zig. CLAUDE.md §État/Tags/decisions/date updated. `zig build` clean; `zig build test` exit 0; `zig fmt --check` green. E6 done — final slice; review = pre-PR review. - 2026-06-28 22:50 — E4 reviewed + GO (diff f51a4e5). E5 exports + binding: added `not_a_module` (E0103), `unknown_export` (E0104), `import_private_item` (E0107) to `DiagnosticCode` + switches. `types.zig`: PER-MODULE exports (`ExportEntry {kind, visibility, arena_index, item_id}` + `ExportTable`; NOT a flat bare-name index — `import a.b {X}` resolves X in module a.b's exports specifically); `ProjectContext` gains `module_index`/`exports[]`/`arenas[]`; new per-file `imported_symbols` + `imported_aliases` maps + deinit; `bindImports` pass (after pass1) emits E0103/E0104, records selective items under their local name, records whole-module/`as m` alias bindings (D-F), E0107 wired-but-dormant via the `.public`-only visibility flag (D-G). `root.zig`: `buildExports` (component/resource/struct/enum/trait/event/fn/type_alias, all-public) per file + threaded into ctx. E5/E6 boundary respected — bindImports records bindings only; TYPE_IDENT application + cross-arena component check are E6. Extended `import_resolve_test.zig` (E0104 / valid-selective-no-diag / E0103). `zig build` clean; `zig build test` exit 0; `zig fmt --check` green. E5 STOP — awaiting review + GO. ## Accepted deviations *Changes to the FROZEN SECTION made mid-milestone after a Claude.ai round-trip. Each references the commit that enacts it. Empty at the end = nominal.* -- +- — const-import test deferred to M1.0.8 (const not parseable until it graduates); cross-file resolution covered by the imported-component type test + the prefab unblock. Authorized by Claude.ai round-trip (GO E5→E6). Enacts the Acceptance › Tests edit on `import_resolve_test.zig`. ## Blockers encountered diff --git a/build.zig b/build.zig index 2973d91..1e2335f 100644 --- a/build.zig +++ b/build.zig @@ -422,9 +422,12 @@ pub fn build(b: *std.Build) void { // (whole / selective / aliased / per-item alias), IDENT+TYPE_IDENT items // (D-D), and malformed-import recovery (resync, no UnsupportedConstructInS3). .{ .path = "tests/etch/import_parse_test.zig", .etch = true }, - // M1.0.7 / E4 — module graph + topological order + cycle detection - // (E0108). E5/E6 extend this file with selective-import resolution. + // M1.0.7 / E4-E6 — module graph + cycle (E0108), exports binding + // (E0103/E0104), cross-file type resolution (no E0102). .{ .path = "tests/etch/import_resolve_test.zig", .etch = true }, + // M1.0.7 / E6 — the E1793 unblock: a `.prefab.etch` importing its + // component types validates clean; an undeclared component still errors. + .{ .path = "tests/etch/crossfile_prefab_import_test.zig", .etch = true }, // M1.0.0 — interpreter ↔ filtered ECS queries: has / not has / value // field-filters (== and ordered) / and-or-not composition + the // dynamic-archetype (never-matches-then-matches) case + the per-rule diff --git a/src/etch/types.zig b/src/etch/types.zig index be16c4b..4a614c3 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -190,6 +190,20 @@ fn methodKey(type_name: StringId, method_name: StringId) u64 { return (@as(u64, type_name) << 32) | @as(u64, method_name); } +/// Resolve a foreign-arena component field's declared type to a `BuiltinType`, +/// or `null` for a non-builtin (named / array / complex) type — M1.0.7 E6 (D-E). +/// Mirrors the builtin path of `namedTypeToResolved` but reads the FOREIGN arena's +/// strings + alias chain; it deliberately does NOT consult any symbol table +/// (cross-module named-type field-type resolution is the documented residual). +fn foreignBuiltinFieldType(decl_arena: *const AstArena, type_node: NodeId) ?BuiltinType { + if (decl_arena.typeNodeKind(type_node) != .named) return null; + const named = decl_arena.named_types.items[decl_arena.typeNodeData(type_node)]; + const resolved = decl_arena.resolveTypeAliasName(named.name); + const tname = decl_arena.strings.slice(resolved); + if (std.mem.eql(u8, tname, "string")) return .string_; + return BuiltinType.fromName(tname); +} + /// `true` if `s` contains an ASCII uppercase letter — an `E1768 /// IdInvalidFormat` data-entry id check (ids are snake_case IDENTs, /// `etch-validation-ecs.md` §22.2; M0.8 E4). @@ -462,6 +476,10 @@ pub const TypeChecker = struct { if (self.symbols.get(ultimate)) |sym| { if (sym.kind == .component or sym.kind == .resource) continue; } + // M1.0.7 E6 — the alias target may be a selectively-imported type. + if (self.imported_symbols.get(ultimate)) |entry| { + if (entry.kind == .component or entry.kind == .resource) continue; + } try self.emit(.undefined_symbol, .error_, self.arena.typeNodeSpan(decl.target), "type alias '{s}' does not resolve to a known type", .{self.arena.strings.slice(decl.name)}); } } @@ -1335,17 +1353,71 @@ pub const TypeChecker = struct { /// Resolve a `component_instance` against the component RTTI: `code_type` /// if the type is not a declared component, then per-field checks. + /// + /// M1.0.7 E6 (D-E): the component type may be a SELECTIVELY-IMPORTED symbol + /// whose declaration lives in another file's arena. When it is, the decl is + /// fetched from `project.arenas[entry.arena_index]` and the fields are checked + /// CROSS-ARENA (field names compared by bytes — StringIds are per-arena). This + /// is the E1793 unblock: a `.prefab.etch` importing its components validates + /// clean instead of tripping `PrefabComponentTypeUnknown`. fn checkComponentInstance(self: *TypeChecker, ci: ast_mod.ComponentInstance, code_type: DiagnosticCode, code_field: DiagnosticCode, code_field_type: DiagnosticCode) !void { - const sym = self.symbols.get(ci.type_name); - if (sym == null or sym.?.kind != .component) { - try self.emit(code_type, .error_, ci.span, "'{s}' is not a declared component", .{self.arena.strings.slice(ci.type_name)}); - return; - } - const decl = self.arena.component_decls.items[self.arena.itemData(sym.?.item_id)]; const owner = self.arena.strings.slice(ci.type_name); + // 1. Local component (the M0.8 path). + if (self.symbols.get(ci.type_name)) |sym| { + if (sym.kind == .component) { + const decl = self.arena.component_decls.items[self.arena.itemData(sym.item_id)]; + var f: u32 = 0; + while (f < ci.fields_len) : (f += 1) { + try self.checkInstanceField(owner, decl.fields_start, decl.fields_len, self.arena.struct_lit_fields.items[ci.fields_start + f], code_field, code_field_type); + } + return; + } + // A local symbol that is NOT a component → fall through to code_type. + } else if (self.imported_symbols.get(ci.type_name)) |entry| { + // 2. Imported component (cross-arena, E6 / D-E). + if (entry.kind == .component) { + const decl_arena = &self.project.?.arenas[entry.arena_index]; + const decl = decl_arena.component_decls.items[decl_arena.itemData(entry.item_id)]; + var f: u32 = 0; + while (f < ci.fields_len) : (f += 1) { + try self.checkInstanceFieldForeign(decl_arena, owner, decl.fields_start, decl.fields_len, self.arena.struct_lit_fields.items[ci.fields_start + f], code_field, code_field_type); + } + return; + } + } + try self.emit(code_type, .error_, ci.span, "'{s}' is not a declared component", .{owner}); + } + + /// Cross-arena field check for an imported component instance (M1.0.7 E6, + /// D-E). The instance field (`field`) lives in `self.arena`; the declared + /// fields live in `decl_arena`. Field names are matched by BYTES (StringIds + /// are per-arena). `code_unknown` (E1794) is full; the field-TYPE check + /// (`code_type`, E1795) runs for BUILTIN-typed foreign fields only — a named + /// foreign field type (struct/enum/component) would need the foreign module's + /// resolved symbol table, which this pass does not hold, so it is skipped + /// (documented residual, not a hack). + fn checkInstanceFieldForeign(self: *TypeChecker, decl_arena: *const AstArena, owner: []const u8, decl_fields_start: u32, decl_fields_len: u32, field: ast_mod.StructLitField, code_unknown: DiagnosticCode, code_type: DiagnosticCode) !void { + if (field.name == 0) return; // spread — not produced in component bodies + const field_name_bytes = self.arena.strings.slice(field.name); + var declared_type_node: ?NodeId = null; var f: u32 = 0; - while (f < ci.fields_len) : (f += 1) { - try self.checkInstanceField(owner, decl.fields_start, decl.fields_len, self.arena.struct_lit_fields.items[ci.fields_start + f], code_field, code_field_type); + while (f < decl_fields_len) : (f += 1) { + const df = decl_arena.fields.items[decl_fields_start + f]; + if (std.mem.eql(u8, decl_arena.strings.slice(df.name), field_name_bytes)) { + declared_type_node = df.type_node; + break; + } + } + const tn = declared_type_node orelse { + try self.emit(code_unknown, .error_, self.arena.exprSpan(field.value), "'{s}' has no field '{s}'", .{ owner, field_name_bytes }); + return; + }; + // Field-TYPE check, builtins only (the POD-common case). A non-builtin + // foreign declared type is skipped (residual). + const declared_builtin = foreignBuiltinFieldType(decl_arena, tn) orelse return; + const actual = try self.synthExprE(field.value, null); + if (actual == .builtin and !self.literalTypeFits(declared_builtin, field.value, actual.builtin)) { + try self.emit(code_type, .error_, self.arena.exprSpan(field.value), "field '{s}' value type does not match its declared type", .{field_name_bytes}); } } @@ -3282,6 +3354,20 @@ pub const TypeChecker = struct { else => .unknown, }; } + // M1.0.7 E6 — a selectively-imported type resolves in any type + // position (`type HA = Health`, field types, signatures). Identity + // is the local-name `StringId`; the defining arena is reached via + // `imported_symbols` only where the decl itself is needed (the + // cross-arena component check, `checkComponentInstance`). + if (self.imported_symbols.get(resolved_name)) |entry| { + return switch (entry.kind) { + .component => .{ .component = resolved_name }, + .resource => .{ .resource = resolved_name }, + .struct_ => .{ .struct_t = resolved_name }, + .enum_ => .{ .enum_t = resolved_name }, + else => .unknown, + }; + } return .unknown; }, .generic => { diff --git a/tests/etch/crossfile_prefab_import_test.zig b/tests/etch/crossfile_prefab_import_test.zig new file mode 100644 index 0000000..57662e7 --- /dev/null +++ b/tests/etch/crossfile_prefab_import_test.zig @@ -0,0 +1,75 @@ +//! M1.0.7 / E6 — the E1793 unblock (the milestone's headline deliverable). +//! +//! A `.prefab.etch` cannot declare its own components (typed-extension cardinality +//! = exactly one `prefab`); it must `import` them. Before cross-file import, a +//! valid prefab's component references wrongly tripped `E1793 +//! PrefabComponentTypeUnknown`. With E6's cross-arena component resolution, a +//! prefab that imports its component types validates clean, and E1793 fires only +//! for a genuinely-undeclared component. + +const std = @import("std"); +const etch = @import("weld_etch"); +const DiagnosticCode = etch.diagnostics.DiagnosticCode; + +fn countCode(diags: []const etch.Diagnostic, code: DiagnosticCode) usize { + var n: usize = 0; + for (diags) |d| { + if (d.code == code) n += 1; + } + return n; +} + +fn deinitDiags(gpa: std.mem.Allocator, diags: *std.ArrayListUnmanaged(etch.Diagnostic)) void { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); +} + +test "prefab importing its components validates clean" { + const gpa = std.testing.allocator; + // `Transform` + `Health` are declared in `components.etch`; the prefab imports + // them and instantiates them with valid fields → no E1793 (type), no E1794 + // (field), no E1795 (field type). This is the cross-arena resolution path. + const files = [_]etch.ProjectFile{ + .{ .name = "components.etch", .source = + \\component Transform { x: float = 0.0 } + \\component Health { current: float = 100.0 } + }, + .{ .name = "goblin.prefab.etch", .source = + \\import components { Transform, Health } + \\prefab "Goblin" { + \\ entity "root" { + \\ Transform { x: 1.0 } + \\ Health { current: 50.0 } + \\ } + \\} + }, + }; + 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, .prefab_component_type_unknown)); + try std.testing.expectEqual(@as(usize, 0), countCode(diags.items, .prefab_component_field_unknown)); + try std.testing.expectEqual(@as(usize, 0), countCode(diags.items, .prefab_component_field_type_invalid)); +} + +test "prefab with an undeclared component still errors" { + const gpa = std.testing.allocator; + // `Transform` is imported (resolves); `Ghost` is declared nowhere → exactly + // one E1793. Confirms the unblock did not blind the check. + const files = [_]etch.ProjectFile{ + .{ .name = "components.etch", .source = "component Transform { x: float = 0.0 }" }, + .{ .name = "goblin.prefab.etch", .source = + \\import components { Transform } + \\prefab "Goblin" { + \\ entity "root" { + \\ Transform { x: 1.0 } + \\ Ghost {} + \\ } + \\} + }, + }; + 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, .prefab_component_type_unknown)); +} diff --git a/tests/etch/import_resolve_test.zig b/tests/etch/import_resolve_test.zig index ffb2454..a9834d3 100644 --- a/tests/etch/import_resolve_test.zig +++ b/tests/etch/import_resolve_test.zig @@ -52,6 +52,24 @@ test "linear import is not a cycle" { try std.testing.expectEqual(@as(usize, 0), countCode(diags.items, .import_cycle)); } +test "selective import resolves a cross-file type" { + const gpa = std.testing.allocator; + // `main` imports the component `Health` from `lib` and uses it in a type + // position (`type HA = Health`). The imported `TYPE_IDENT` must resolve — + // no E0102 UndefinedSymbol (E6 applies the imported set to type resolution). + const files = [_]etch.ProjectFile{ + .{ .name = "lib.etch", .source = "component Health { current: float = 100.0 }" }, + .{ .name = "main.etch", .source = + \\import lib { Health } + \\type HA = Health + }, + }; + 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, .undefined_symbol)); +} + test "unknown export errors (E0104)" { const gpa = std.testing.allocator; // `lib` exports `Health`; `main` selectively imports `Nope`, which `lib` does From 131fbd3f70bc4bd52eada4fd6b0c1139911bd256 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 29 Jun 2026 00:58:41 +0200 Subject: [PATCH 10/13] docs(claude-md): update for M1.0.7 --- CLAUDE.md | 8 +++++--- briefs/M1.0.7-cross-file-import.md | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fc053df..e59230b 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.6-prefabs-crossrefs-extensions` | +| Last released tag | `v0.10.7-cross-file-import` | | Active branch | `main` | -| Next planned milestone | M1.0.9 — extension hook (`on_attach`/`on_detach`) execution; starts with a text-vs-bytecode design decision (re-scoped out of M1.0.6) | +| 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. | ## Tags @@ -44,6 +44,7 @@ knowledge base — see § Quick links spec. | `v0.10.4-scene-cook` | 2026-06-27 | M1.0.4 — Cooking `.scene.etch` → `.scene.bin` | Offline, World-free cook of direct-entity scenes. Tier-0 `src/core/scene/` codec — `SceneHeader` (64 B) + §10 Schema Registry + `writer` + zero-copy `accessor` (the read half, reused verbatim by the M1.0.5 loader). Etch driver `src/etch/scene_cook.zig` reuses `compileTypeDecl` (refactored `*World`→`*Registry`) + `evalConst`; groups entities by `ComponentSignature` into flat SoA columns. On-disk component identity is the Schema-Registry index → component **name** (no raw `ComponentId`). Resource `string`/enum materialized; `parent` name→UUID; `instance of` + unsupported field kinds rejected with clear diagnostics. `scene_cook` CLI + re-cook byte-identical determinism. | | `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. | ## Hypotheses validated by spikes @@ -73,6 +74,7 @@ knowledge base — see § Quick links spec. - **M1.0.6 scope boundary**: prefab `instance of` flattened at scene cook + `.prefab.bin` standalone/`of` cook; entity→entity cross-refs by name via `FieldKind.entity_` (resolved at load); extension **activation** = Entity Extensions Table + Prefab ID Table + add-component at load + Tier-0 `on_attach` dispatch seam. Hook **execution** (compile/run the Etch hook text) is **M1.0.9** — the interpreter is compile-once from the AST with no runtime text-execution surface, and the text-vs-bytecode serialization choice is unsettled. The §30.5 additive-conflict **compile-time** warning is also M1.0.9 (the cook's `diag_out` is mono-message set-on-error; a non-fatal warning channel is needed); the dangerous **runtime** case is already caught (`ExtensionComponentConflict` at load). - **`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). **Residuals**: cross-arena field-TYPE check (E1795) is builtin-typed-only — a named foreign field type (struct/enum) would need the foreign module's resolved symbol table (out of this pass), deferred; 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. ## Non-negotiable rules @@ -206,4 +208,4 @@ The `briefs/` directory is the source of truth for milestone state. The brief's --- -Last updated: 2026-06-28 +Last updated: 2026-06-29 diff --git a/briefs/M1.0.7-cross-file-import.md b/briefs/M1.0.7-cross-file-import.md index 702b56e..f9e9e32 100644 --- a/briefs/M1.0.7-cross-file-import.md +++ b/briefs/M1.0.7-cross-file-import.md @@ -194,7 +194,7 @@ Taken with Guy before coding. They are the contract; do not re-litigate. The thr *Changes to the FROZEN SECTION made mid-milestone after a Claude.ai round-trip. Each references the commit that enacts it. Empty at the end = nominal.* -- — const-import test deferred to M1.0.8 (const not parseable until it graduates); cross-file resolution covered by the imported-component type test + the prefab unblock. Authorized by Claude.ai round-trip (GO E5→E6). Enacts the Acceptance › Tests edit on `import_resolve_test.zig`. +- 6d4f344 — const-import test deferred to M1.0.8 (const not parseable until it graduates); cross-file resolution covered by the imported-component type test + the prefab unblock. Authorized by Claude.ai round-trip (GO E5→E6). Enacts the Acceptance › Tests edit on `import_resolve_test.zig`. ## Blockers encountered From c99affedd2664a4506bc670f8e30e47b21332272 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 29 Jun 2026 01:23:42 +0200 Subject: [PATCH 11/13] docs: reframe foreign-field-type note as moot (not debt) --- CLAUDE.md | 2 +- src/etch/types.zig | 23 +++++++++++++++-------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e59230b..b44a67b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,7 @@ knowledge base — see § Quick links spec. - **M1.0.6 scope boundary**: prefab `instance of` flattened at scene cook + `.prefab.bin` standalone/`of` cook; entity→entity cross-refs by name via `FieldKind.entity_` (resolved at load); extension **activation** = Entity Extensions Table + Prefab ID Table + add-component at load + Tier-0 `on_attach` dispatch seam. Hook **execution** (compile/run the Etch hook text) is **M1.0.9** — the interpreter is compile-once from the AST with no runtime text-execution surface, and the text-vs-bytecode serialization choice is unsettled. The §30.5 additive-conflict **compile-time** warning is also M1.0.9 (the cook's `diag_out` is mono-message set-on-error; a non-fatal warning channel is needed); the dangerous **runtime** case is already caught (`ExtensionComponentConflict` at load). - **`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). **Residuals**: cross-arena field-TYPE check (E1795) is builtin-typed-only — a named foreign field type (struct/enum) would need the foreign module's resolved symbol table (out of this pass), deferred; 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.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. ## Non-negotiable rules diff --git a/src/etch/types.zig b/src/etch/types.zig index 4a614c3..9e41e3b 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -193,8 +193,10 @@ fn methodKey(type_name: StringId, method_name: StringId) u64 { /// Resolve a foreign-arena component field's declared type to a `BuiltinType`, /// or `null` for a non-builtin (named / array / complex) type — M1.0.7 E6 (D-E). /// Mirrors the builtin path of `namedTypeToResolved` but reads the FOREIGN arena's -/// strings + alias chain; it deliberately does NOT consult any symbol table -/// (cross-module named-type field-type resolution is the documented residual). +/// strings + alias chain; it consults no symbol table. `null` is unreachable for a +/// valid component (component fields are builtin-POD only, `validateFieldsInDecl +/// .component_like`) — so the cross-arena field-TYPE check is complete for every +/// valid imported component. fn foreignBuiltinFieldType(decl_arena: *const AstArena, type_node: NodeId) ?BuiltinType { if (decl_arena.typeNodeKind(type_node) != .named) return null; const named = decl_arena.named_types.items[decl_arena.typeNodeData(type_node)]; @@ -1392,10 +1394,13 @@ pub const TypeChecker = struct { /// D-E). The instance field (`field`) lives in `self.arena`; the declared /// fields live in `decl_arena`. Field names are matched by BYTES (StringIds /// are per-arena). `code_unknown` (E1794) is full; the field-TYPE check - /// (`code_type`, E1795) runs for BUILTIN-typed foreign fields only — a named - /// foreign field type (struct/enum/component) would need the foreign module's - /// resolved symbol table, which this pass does not hold, so it is skipped - /// (documented residual, not a hack). + /// (`code_type`, E1795) resolves the foreign declared type via + /// `foreignBuiltinFieldType`. That covers EVERY valid component field type: + /// `validateFieldsInDecl(.component_like)` admits only builtin-POD field types + /// (named struct/enum/string are rejected on components), so a valid imported + /// component's fields are all builtins. The `orelse return` (named foreign + /// type) is therefore unreachable for a valid component — forward-compat + /// headroom if components ever gain named-typed fields, not a skipped check. fn checkInstanceFieldForeign(self: *TypeChecker, decl_arena: *const AstArena, owner: []const u8, decl_fields_start: u32, decl_fields_len: u32, field: ast_mod.StructLitField, code_unknown: DiagnosticCode, code_type: DiagnosticCode) !void { if (field.name == 0) return; // spread — not produced in component bodies const field_name_bytes = self.arena.strings.slice(field.name); @@ -1412,8 +1417,10 @@ pub const TypeChecker = struct { try self.emit(code_unknown, .error_, self.arena.exprSpan(field.value), "'{s}' has no field '{s}'", .{ owner, field_name_bytes }); return; }; - // Field-TYPE check, builtins only (the POD-common case). A non-builtin - // foreign declared type is skipped (residual). + // Field-TYPE check. Component fields are builtin-POD only + // (validateFieldsInDecl .component_like), so this resolves for every + // valid imported component; the `orelse return` is unreachable for a + // valid component (forward-compat headroom). const declared_builtin = foreignBuiltinFieldType(decl_arena, tn) orelse return; const actual = try self.synthExprE(field.value, null); if (actual == .builtin and !self.literalTypeFits(declared_builtin, field.value, actual.builtin)) { From f73918933bc3e862b840eb0ade9bdb2b79434823 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 29 Jun 2026 01:23:43 +0200 Subject: [PATCH 12/13] docs(brief): close M1.0.7 --- briefs/M1.0.7-cross-file-import.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/briefs/M1.0.7-cross-file-import.md b/briefs/M1.0.7-cross-file-import.md index f9e9e32..e92a6f6 100644 --- a/briefs/M1.0.7-cross-file-import.md +++ b/briefs/M1.0.7-cross-file-import.md @@ -1,12 +1,12 @@ # M1.0.7 — Cross-file `import` (resolver pass-1) -> **Status:** ACTIVE +> **Status:** CLOSED > **Phase:** 1 > **Branch:** `phase-1/etch/cross-file-import` > **Tag (set after merge by Guy):** `v0.10.7-cross-file-import` > **Dependencies:** M0.8 (grammar v0.6: the `declaration` dispatch + `parseTopLevel`, the `non_s3_keywords` reserve list that holds `import`, the `kw_as` token from casts), M0.9 (E2-B cross-file `validateProject` + `TypeChecker.ProjectContext` byte-keyed `prefabs`/`uuids` indexes + `checkProject` — the seam this milestone extends), M1.0.6 (`validatePrefab` / `checkComponentInstance` and `.prefab.etch` cooking — the E1793 site this milestone unblocks) > **Open date:** 2026-06-28 -> **Close date:** — +> **Close date:** 2026-06-29 --- @@ -187,6 +187,7 @@ Taken with Guy before coding. They are the contract; do not re-litigate. The thr - 2026-06-28 21:50 — E1 reviewed + GO (diff 11f6622). E2 AST: read the arena patterns (ComponentDecl `_start`/`_len`, PrefabDecl, the `tag_path_segs`/`prefab_requires` StringId-run precedent). Added `ImportItem {name, alias}` + `ImportDecl {path run, module_alias, items run}` structs, 3 slabs (`import_decls`/`import_path_segs`/`import_items`) + deinit, `addImportDecl` helper. Wired to the pre-existing `ItemKind.import_decl` variant (additive; span carried at the Item slab). D-D accommodated (name/alias are StringIds — IDENT and TYPE_IDENT both fit). `zig build` clean; `zig test src/etch/ast.zig` 6/6; `zig fmt --check` green. E2 STOP — awaiting review + GO. - 2026-06-28 22:05 — E2 reviewed + GO (diff 7894c2e). E3 parser: `parseImportDecl` (module_path = `.ident`-only run; `as IDENT` module alias; `{ … }` selective spec) + `parseImportItem` (name/alias accept `.ident` AND `.type_ident`, D-D); `kw_import` arm in `parseTopLevel`; `kw_import` added to the `recoverToTopLevel` stop-set + the `else` message. `import` discards annotations (bare `declaration`, `parseTypeAliasDecl` precedent). Created `tests/etch/import_parse_test.zig` (4-forms / IDENT+TYPE_IDENT items / malformed-recovers) + wired the `.etch` target in build.zig (artifact rooted at the test file → no lazy-skip). `zig build` clean; `zig build test` exit 0 (full suite, incl. the 3 new tests; the `failed command` lines are the known macOS cosmetic noise); `zig fmt --check` green. E3 STOP — awaiting review + GO. - 2026-06-28 22:25 — E3 reviewed + GO (diff ae3d154). E4 module graph: added `import_cycle` (E0108) to `DiagnosticCode` + `code()`/`name()` (D-B: E0108, not E0101). `root.zig` `validateProject`: `deriveModulePath` (strip `src/` + `.etch`/typed compound ext, `/`→`.`) + `joinImportPath`; module-path→index map; directed importer→imported graph (edges only between files present in the set — unresolved targets are an E5 concern); iterative DFS → post-order topo order + back-edge → one E0108 (span at the closing import); checkProject now runs in topo order (deps first), input-order fallback on cycle. Verified the resolver tolerates `import_decl` items (`pass1Collect` switch ends `else => {}` forward-compat). Vigilance point: confirmed the crossfile fixtures use bare `*.etch` names (no `src/` prefix) → derivation handles both; typed-ext module label is harmless (typed files are never import targets). Created `tests/etch/import_resolve_test.zig` (cycle → E0108; linear → no E0108) + wired build.zig. `zig build` clean; `zig build test` exit 0; `zig fmt --check` green. E4 STOP — awaiting review + GO. +- 2026-06-29 10:15 — E6 reviewed + GO (diff 6d4f344); milestone validated on substance. Two corrections per review: (1) reframed the foreign-field-type note as MOOT not debt — confirmed against code that `validateFieldsInDecl(.component_like)` admits builtin-POD field types only, so `foreignBuiltinFieldType` covers every valid component field → the named-type branch is unreachable (forward-compat headroom); updated the `checkInstanceFieldForeign`/`foreignBuiltinFieldType` doc-comments + the CLAUDE.md residuals line. (2) backfilled the Accepted-deviations SHA (6d4f344). Étape 4 validation: `zig build` / `zig fmt --check src/ tests/ build.zig` / `zig build lint` / `zig build test` (Debug) all green; ReleaseSafe via pre-push. Closing notes filled; Status → CLOSED. - 2026-06-29 09:30 — E5 reviewed + GO (diff a104dea). E6 apply + cross-arena (final slice): `namedTypeToResolved` + `validateTypeAliases` fall back to `imported_symbols` (imported `TYPE_IDENT` resolves in type positions — `type HA = Health` no longer E0102). `checkComponentInstance` (D-E): local component → existing path; imported component → fetch decl from `project.arenas[entry.arena_index]` + `checkInstanceFieldForeign` (cross-arena, field names by BYTES); else E1793. `checkInstanceFieldForeign` does E1794 (field-name byte-keyed) full + E1795 for BUILTIN foreign field types only (`foreignBuiltinFieldType`, no symbol-table dependency); named foreign field types are the documented residual (would balloon — needs the foreign module's resolved symbols). E1793 unblock works. Const-import test deferred to M1.0.8 (Claude.ai GO E5→E6 decision; FROZEN Acceptance edit + Accepted-deviations entry). Extended `import_resolve_test.zig` (cross-file type resolves, no E0102); created `tests/etch/crossfile_prefab_import_test.zig` (prefab imports its components → clean; undeclared → E1793) + wired build.zig. CLAUDE.md §État/Tags/decisions/date updated. `zig build` clean; `zig build test` exit 0; `zig fmt --check` green. E6 done — final slice; review = pre-PR review. - 2026-06-28 22:50 — E4 reviewed + GO (diff f51a4e5). E5 exports + binding: added `not_a_module` (E0103), `unknown_export` (E0104), `import_private_item` (E0107) to `DiagnosticCode` + switches. `types.zig`: PER-MODULE exports (`ExportEntry {kind, visibility, arena_index, item_id}` + `ExportTable`; NOT a flat bare-name index — `import a.b {X}` resolves X in module a.b's exports specifically); `ProjectContext` gains `module_index`/`exports[]`/`arenas[]`; new per-file `imported_symbols` + `imported_aliases` maps + deinit; `bindImports` pass (after pass1) emits E0103/E0104, records selective items under their local name, records whole-module/`as m` alias bindings (D-F), E0107 wired-but-dormant via the `.public`-only visibility flag (D-G). `root.zig`: `buildExports` (component/resource/struct/enum/trait/event/fn/type_alias, all-public) per file + threaded into ctx. E5/E6 boundary respected — bindImports records bindings only; TYPE_IDENT application + cross-arena component check are E6. Extended `import_resolve_test.zig` (E0104 / valid-selective-no-diag / E0103). `zig build` clean; `zig build test` exit 0; `zig fmt --check` green. E5 STOP — awaiting review + GO. @@ -206,8 +207,8 @@ Taken with Guy before coding. They are the contract; do not re-litigate. The thr *Filled at Status → CLOSED, just before opening the PR.* -- **What worked**: -- **What deviated from the original spec**: -- **What to flag explicitly in review**: -- **Final measures** (perf, binary size, compile time, whatever is relevant): -- **Residual risk / tech debt left deliberately**: +- **What worked**: The parser-up graduation (D-A) was clean — `import` was the last reserved top-level keyword still in `non_s3_keywords`, and the table-driven lexer meant E1 was a 3-line change + tests. The M0.9 byte-keyed `ProjectContext` pattern extended naturally to a **per-module** exports index; keying cross-file resolution on string BYTES (not per-arena StringIds) is the load-bearing idea and it held end-to-end (graph nodes, exports, cross-arena field names). The iterative DFS gives both the topological order (deps-first `checkProject`) and the cycle back-edge for E0108 in one pass. The cross-arena component check (D-E) localized to `checkComponentInstance` + one new `checkInstanceFieldForeign` helper — the rest of the resolver was untouched because every other validate pass already does `if (kinds[i] != .X) continue` (so `import_decl` items are ignored) and `pass1Collect` ends `else => {}`. +- **What deviated from the original spec**: One accepted deviation (Claude.ai round-trip, GO E5→E6): the `test "selective import resolves a cross-file const"` was dropped — `const` top-level is M1.0.8 and is not parseable in M1.0.7, so the test is unwritable. Cross-file resolution is instead proven by `test "selective import resolves a cross-file type"` + the prefab unblock tests. The FROZEN Acceptance › Tests line was edited accordingly (commit 6d4f344, logged under Accepted deviations). +- **What to flag explicitly in review**: (1) The cross-arena field-TYPE check (E1795) resolves builtin field types via `foreignBuiltinFieldType`; this is **complete** for components (not a gap) because `validateFieldsInDecl(.component_like)` admits only builtin-POD field types — the named-type branch is unreachable for a valid component (forward-compat headroom). (2) `deriveModulePath` strips an optional `src/` prefix + the `.etch`/typed-compound extension and maps `/`→`.`; it handles both the bare `*.etch` fixture names and real `src/`-prefixed paths. (3) D-F (module-alias qualified `m.Type`) and D-G (`E0107` until `private`) are wired-but-dormant and recorded so M1.0.8/later are purely additive. +- **Final measures** (perf, binary size, compile time, whatever is relevant): No new benchmark (per brief — the graph + topo-sort + exports index are `O(files + imports + exports)` over a small set, off any runtime hot path). `validateProject` cost did not move materially vs M1.0.6 (same parse-all-then-check shape; the added passes are linear over the already-parsed items). Full `zig build test` (Debug) green; pre-push `test` (Debug) + `test-release` (ReleaseSafe) green. +- **Residual risk / tech debt left deliberately**: None for the cross-arena field-type point (moot — see flag (1), forward-compat headroom only). Deferred-by-design (not debt): module-alias qualified `m.Type` resolution (D-F, binding recorded), `E0107` activation (D-G, until `private` graduates M1.0.8), and cross-file `const` import + its test (M1.0.8, when `const` graduates). From f469b4ee6ffeac174311bb820c2e03259d88a05d Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Mon, 29 Jun 2026 11:45:13 +0200 Subject: [PATCH 13/13] docs(brief): note windows-ReleaseSafe CI flake re-run (M1.0.7) --- briefs/M1.0.7-cross-file-import.md | 1 + 1 file changed, 1 insertion(+) diff --git a/briefs/M1.0.7-cross-file-import.md b/briefs/M1.0.7-cross-file-import.md index e92a6f6..94a0fd2 100644 --- a/briefs/M1.0.7-cross-file-import.md +++ b/briefs/M1.0.7-cross-file-import.md @@ -187,6 +187,7 @@ Taken with Guy before coding. They are the contract; do not re-litigate. The thr - 2026-06-28 21:50 — E1 reviewed + GO (diff 11f6622). E2 AST: read the arena patterns (ComponentDecl `_start`/`_len`, PrefabDecl, the `tag_path_segs`/`prefab_requires` StringId-run precedent). Added `ImportItem {name, alias}` + `ImportDecl {path run, module_alias, items run}` structs, 3 slabs (`import_decls`/`import_path_segs`/`import_items`) + deinit, `addImportDecl` helper. Wired to the pre-existing `ItemKind.import_decl` variant (additive; span carried at the Item slab). D-D accommodated (name/alias are StringIds — IDENT and TYPE_IDENT both fit). `zig build` clean; `zig test src/etch/ast.zig` 6/6; `zig fmt --check` green. E2 STOP — awaiting review + GO. - 2026-06-28 22:05 — E2 reviewed + GO (diff 7894c2e). E3 parser: `parseImportDecl` (module_path = `.ident`-only run; `as IDENT` module alias; `{ … }` selective spec) + `parseImportItem` (name/alias accept `.ident` AND `.type_ident`, D-D); `kw_import` arm in `parseTopLevel`; `kw_import` added to the `recoverToTopLevel` stop-set + the `else` message. `import` discards annotations (bare `declaration`, `parseTypeAliasDecl` precedent). Created `tests/etch/import_parse_test.zig` (4-forms / IDENT+TYPE_IDENT items / malformed-recovers) + wired the `.etch` target in build.zig (artifact rooted at the test file → no lazy-skip). `zig build` clean; `zig build test` exit 0 (full suite, incl. the 3 new tests; the `failed command` lines are the known macOS cosmetic noise); `zig fmt --check` green. E3 STOP — awaiting review + GO. - 2026-06-28 22:25 — E3 reviewed + GO (diff ae3d154). E4 module graph: added `import_cycle` (E0108) to `DiagnosticCode` + `code()`/`name()` (D-B: E0108, not E0101). `root.zig` `validateProject`: `deriveModulePath` (strip `src/` + `.etch`/typed compound ext, `/`→`.`) + `joinImportPath`; module-path→index map; directed importer→imported graph (edges only between files present in the set — unresolved targets are an E5 concern); iterative DFS → post-order topo order + back-edge → one E0108 (span at the closing import); checkProject now runs in topo order (deps first), input-order fallback on cycle. Verified the resolver tolerates `import_decl` items (`pass1Collect` switch ends `else => {}` forward-compat). Vigilance point: confirmed the crossfile fixtures use bare `*.etch` names (no `src/` prefix) → derivation handles both; typed-ext module label is harmless (typed files are never import targets). Created `tests/etch/import_resolve_test.zig` (cycle → E0108; linear → no E0108) + wired build.zig. `zig build` clean; `zig build test` exit 0; `zig fmt --check` green. E4 STOP — awaiting review + GO. +- 2026-06-29 11:30 — CI flake (not a code fix): PR #35 went red on the single `build-and-test (windows-2025, ReleaseSafe)` cell — `error: test runner failed to respond for 1m15s` (a test artifact HUNG), `Build Summary: 840/872 passed (32 skipped)` = **0 failed assertions**. Diagnosed as a flaky external-resource/timing hang, not the E6 surface: `ProjectContext.arenas = asts.items` is stable (`asts` pre-sized + `appendAssumeCapacity`, slice captured post-fill), `ExportEntry`/`Frame` fully initialized, DFS terminates (no loop), and the import tests are pure in-memory (no hang surface). The other 3 CI cells (ubuntu Debug+RS, windows Debug) + the local pre-push (`test` + `test-release` ×2, macOS) were all green — a deterministic code bug would fail uniformly. Re-ran the failed job → **all 8 jobs green** (windows-RS ReleaseSafe success), same commit, no code change. Confirmed flake. `Status` stays CLOSED. - 2026-06-29 10:15 — E6 reviewed + GO (diff 6d4f344); milestone validated on substance. Two corrections per review: (1) reframed the foreign-field-type note as MOOT not debt — confirmed against code that `validateFieldsInDecl(.component_like)` admits builtin-POD field types only, so `foreignBuiltinFieldType` covers every valid component field → the named-type branch is unreachable (forward-compat headroom); updated the `checkInstanceFieldForeign`/`foreignBuiltinFieldType` doc-comments + the CLAUDE.md residuals line. (2) backfilled the Accepted-deviations SHA (6d4f344). Étape 4 validation: `zig build` / `zig fmt --check src/ tests/ build.zig` / `zig build lint` / `zig build test` (Debug) all green; ReleaseSafe via pre-push. Closing notes filled; Status → CLOSED. - 2026-06-29 09:30 — E5 reviewed + GO (diff a104dea). E6 apply + cross-arena (final slice): `namedTypeToResolved` + `validateTypeAliases` fall back to `imported_symbols` (imported `TYPE_IDENT` resolves in type positions — `type HA = Health` no longer E0102). `checkComponentInstance` (D-E): local component → existing path; imported component → fetch decl from `project.arenas[entry.arena_index]` + `checkInstanceFieldForeign` (cross-arena, field names by BYTES); else E1793. `checkInstanceFieldForeign` does E1794 (field-name byte-keyed) full + E1795 for BUILTIN foreign field types only (`foreignBuiltinFieldType`, no symbol-table dependency); named foreign field types are the documented residual (would balloon — needs the foreign module's resolved symbols). E1793 unblock works. Const-import test deferred to M1.0.8 (Claude.ai GO E5→E6 decision; FROZEN Acceptance edit + Accepted-deviations entry). Extended `import_resolve_test.zig` (cross-file type resolves, no E0102); created `tests/etch/crossfile_prefab_import_test.zig` (prefab imports its components → clean; undeclared → E1793) + wired build.zig. CLAUDE.md §État/Tags/decisions/date updated. `zig build` clean; `zig build test` exit 0; `zig fmt --check` green. E6 done — final slice; review = pre-PR review. - 2026-06-28 22:50 — E4 reviewed + GO (diff f51a4e5). E5 exports + binding: added `not_a_module` (E0103), `unknown_export` (E0104), `import_private_item` (E0107) to `DiagnosticCode` + switches. `types.zig`: PER-MODULE exports (`ExportEntry {kind, visibility, arena_index, item_id}` + `ExportTable`; NOT a flat bare-name index — `import a.b {X}` resolves X in module a.b's exports specifically); `ProjectContext` gains `module_index`/`exports[]`/`arenas[]`; new per-file `imported_symbols` + `imported_aliases` maps + deinit; `bindImports` pass (after pass1) emits E0103/E0104, records selective items under their local name, records whole-module/`as m` alias bindings (D-F), E0107 wired-but-dormant via the `.public`-only visibility flag (D-G). `root.zig`: `buildExports` (component/resource/struct/enum/trait/event/fn/type_alias, all-public) per file + threaded into ctx. E5/E6 boundary respected — bindImports records bindings only; TYPE_IDENT application + cross-arena component check are E6. Extended `import_resolve_test.zig` (E0104 / valid-selective-no-diag / E0103). `zig build` clean; `zig build test` exit 0; `zig fmt --check` green. E5 STOP — awaiting review + GO.